| 1 | //! /share command — publish a redacted copy of the session as a secret gist. |
| 2 | //! |
| 3 | //! The page is rendered through the same projection and renderer as |
| 4 | //! `/export`, so hidden instructions, internal reasoning, and reasoning |
| 5 | //! signatures are dropped and secret-like values are redacted before anything |
| 6 | //! leaves the machine. The raw `api_messages` JSON never reaches the page. |
| 7 | //! |
| 8 | //! # Usage |
| 9 | //! |
| 10 | //! - `/share` — preview only: what would be uploaded; no network call |
| 11 | //! - `/share confirm` — upload as a secret gist (unlisted, not private) |
| 12 | //! - `/share help` — show usage |
| 13 | |
| 14 | use std::io::Write; |
| 15 | use std::path::Path; |
| 16 | |
| 17 | use codewhale_command_contract::facets::{ |
| 18 | CommandSessionExportContext, ConversationExportProjection, ExportBlock, RestorePointProjection, |
| 19 | TranscriptProjection, |
| 20 | }; |
| 21 | use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; |
| 22 | use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; |
| 23 | |
| 24 | use crate::commands::CommandResult; |
| 25 | use crate::commands::groups::session::export::render_conversation; |
| 26 | use crate::dependencies::ExternalTool; |
| 27 | use crate::tui::app::AppAction; |
| 28 | |
| 29 | const VISIBILITY_NOTE: &str = |
| 30 | "a secret GitHub gist (unlisted, not private: anyone with the link can read it)"; |
| 31 | |
| 32 | /// Preview or share the current session. |
| 33 | fn share(export: &dyn CommandSessionExportContext, arg: Option<&str>) -> CommandResult { |
| 34 | let raw = arg.map(str::trim).unwrap_or(""); |
| 35 | |
| 36 | match raw { |
| 37 | "" => preview(export), |
| 38 | "confirm" => confirm(export), |
| 39 | "help" | "--help" | "-h" => CommandResult::message(format!( |
| 40 | "/share — Share a redacted copy of this session.\n\ |
| 41 | \n\ |
| 42 | Usage:\n\ |
| 43 | /share Preview what would be uploaded (nothing leaves the machine)\n\ |
| 44 | /share confirm Upload it as {VISIBILITY_NOTE}\n\ |
| 45 | \n\ |
| 46 | The page uses the same redacted rendering as /export: hidden\n\ |
| 47 | instructions and reasoning are omitted and secret-like values are\n\ |
| 48 | masked. Uploading needs the `gh` CLI, installed and signed in." |
| 49 | )), |
| 50 | _ => CommandResult::error(format!( |
| 51 | "Unknown /share argument `{raw}`. Use `/share`, `/share confirm`, or `/share help`." |
| 52 | )), |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | /// What `/share` would publish, measured on the exact page it would upload. |
| 57 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 58 | struct SharePlan { |
| 59 | markdown: String, |
| 60 | model: String, |
| 61 | mode: String, |
| 62 | message_count: usize, |
| 63 | /// `None` when the page is the visible-history fallback, which may carry |
| 64 | /// tool output it cannot count. |
| 65 | tool_results: Option<usize>, |
| 66 | redactions: usize, |
| 67 | } |
| 68 | |
| 69 | /// Project the conversation the way `/export` does, minus the local |
| 70 | /// restore-point table (snapshot ids mean nothing to a reader of the link). |
| 71 | fn plan(export: &dyn CommandSessionExportContext) -> Option<SharePlan> { |
| 72 | let mut projection: ConversationExportProjection = export.conversation_projection(); |
| 73 | if projection.metadata.message_count == 0 { |
| 74 | return None; |
| 75 | } |
| 76 | projection.restore_points = RestorePointProjection::None; |
| 77 | // The visible-history fallback cannot tell tool output apart from the |
| 78 | // rest, so it is not counted as "none". |
| 79 | let tool_results = match &projection.transcript { |
| 80 | TranscriptProjection::Authoritative(messages) => Some( |
| 81 | messages |
| 82 | .iter() |
| 83 | .flat_map(|message| &message.blocks) |
| 84 | .filter(|block| matches!(block, ExportBlock::ToolResult { .. })) |
| 85 | .count(), |
| 86 | ), |
| 87 | TranscriptProjection::HistoryFallback(_) => None, |
| 88 | }; |
| 89 | let model = projection.metadata.model.clone(); |
| 90 | let mode = projection.metadata.mode.clone(); |
| 91 | let message_count = projection.metadata.message_count; |
| 92 | let markdown = render_conversation(projection); |
| 93 | let redactions = count_redactions(&markdown); |
| 94 | Some(SharePlan { |
| 95 | markdown, |
| 96 | model, |
| 97 | mode, |
| 98 | message_count, |
| 99 | tool_results, |
| 100 | redactions, |
| 101 | }) |
| 102 | } |
| 103 | |
| 104 | /// Count the redaction markers the shared sanitizer leaves behind: `[redacted…]` |
| 105 | /// for secrets and keys, `***:***@` for URL credentials, and an encoded `***` |
| 106 | /// for sensitive URL query values. |
| 107 | fn count_redactions(markdown: &str) -> usize { |
| 108 | ["[redacted", "***:***@", "=%2A%2A%2A"] |
| 109 | .iter() |
| 110 | .map(|marker| markdown.matches(marker).count()) |
| 111 | .sum() |
| 112 | } |
| 113 | |
| 114 | const NOTHING_TO_SHARE: &str = "Nothing to share. The current session is empty."; |
| 115 | |
| 116 | /// The counts both steps print, measured on the page itself. `confirm` |
| 117 | /// re-renders, so it states what it actually uploads rather than repeating |
| 118 | /// the preview's numbers (messages that arrived in between are included). |
| 119 | fn plan_summary(plan: &SharePlan) -> String { |
| 120 | let tool_output = match plan.tool_results { |
| 121 | Some(0) => "none".to_string(), |
| 122 | Some(count) => format!("included ({count} tool result(s), redacted)"), |
| 123 | None => "may be included (visible history, redacted)".to_string(), |
| 124 | }; |
| 125 | format!( |
| 126 | "Messages: {}\n\ |
| 127 | Tool output: {tool_output}\n\ |
| 128 | Redacted: {} item(s)\n\ |
| 129 | Omitted: hidden instructions, internal reasoning, reasoning signatures", |
| 130 | plan.message_count, plan.redactions |
| 131 | ) |
| 132 | } |
| 133 | |
| 134 | /// Plain `/share`: describe the page, upload nothing. |
| 135 | fn preview(export: &dyn CommandSessionExportContext) -> CommandResult { |
| 136 | let Some(plan) = plan(export) else { |
| 137 | return CommandResult::error(NOTHING_TO_SHARE); |
| 138 | }; |
| 139 | CommandResult::message(format!( |
| 140 | "Share preview — nothing has been uploaded.\n\ |
| 141 | \n\ |
| 142 | {}\n\ |
| 143 | \n\ |
| 144 | Run `/share confirm` to upload this page as {VISIBILITY_NOTE}.\n\ |
| 145 | Use `/export file <path>` to read the exact text first.", |
| 146 | plan_summary(&plan) |
| 147 | )) |
| 148 | } |
| 149 | |
| 150 | /// `/share confirm`: hand the rendered, redacted page to the host to upload. |
| 151 | fn confirm(export: &dyn CommandSessionExportContext) -> CommandResult { |
| 152 | let Some(plan) = plan(export) else { |
| 153 | return CommandResult::error(NOTHING_TO_SHARE); |
| 154 | }; |
| 155 | let html = render_session_html(&plan.markdown, &plan.model, &plan.mode); |
| 156 | CommandResult::with_message_and_action( |
| 157 | format!( |
| 158 | "Uploading this page as {VISIBILITY_NOTE}...\n\ |
| 159 | \n\ |
| 160 | {}", |
| 161 | plan_summary(&plan) |
| 162 | ), |
| 163 | AppAction::ShareSession { html }, |
| 164 | ) |
| 165 | } |
| 166 | |
| 167 | /// Upload a rendered page, then delete the local temp copy. |
| 168 | /// |
| 169 | /// Called from the UI loop for `AppAction::ShareSession`. The upload runs on a |
| 170 | /// blocking thread because it shells out to `gh`. |
| 171 | pub async fn perform_share(html: String) -> Result<String, String> { |
| 172 | tokio::task::spawn_blocking(move || upload_via_temp_file(&html, upload_gist)) |
| 173 | .await |
| 174 | .map_err(|join_err| format!("share upload panicked: {join_err}"))? |
| 175 | } |
| 176 | |
| 177 | /// Write `html` to a private temp file, run `upload` on its path, and remove |
| 178 | /// the file whatever the upload returned. |
| 179 | fn upload_via_temp_file( |
| 180 | html: &str, |
| 181 | upload: impl FnOnce(&Path) -> Result<String, String>, |
| 182 | ) -> Result<String, String> { |
| 183 | let tmp = write_temp_html(html).map_err(|e| format!("Failed to write temp file: {e}"))?; |
| 184 | let result = upload(tmp.path()); |
| 185 | let removed = tmp.close(); |
| 186 | let url = result?; |
| 187 | removed |
| 188 | .map_err(|e| format!("Shared at {url}, but the local temp copy was not removed: {e}"))?; |
| 189 | Ok(url) |
| 190 | } |
| 191 | |
| 192 | /// Render the (already redacted) conversation as a standalone HTML page. |
| 193 | fn render_session_html(markdown: &str, model: &str, mode: &str) -> String { |
| 194 | let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"); |
| 195 | let escaped_model = html_escape(model); |
| 196 | let escaped_mode = html_escape(mode); |
| 197 | let escaped_body = html_escape(markdown); |
| 198 | |
| 199 | format!( |
| 200 | r#"<!DOCTYPE html> |
| 201 | <html lang="en"> |
| 202 | <head> |
| 203 | <meta charset="UTF-8"> |
| 204 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 205 | <title>codewhale Session Export</title> |
| 206 | <style> |
| 207 | body {{ |
| 208 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; |
| 209 | max-width: 800px; margin: 2rem auto; padding: 0 1rem; |
| 210 | background: #0d1117; color: #c9d1d9; |
| 211 | }} |
| 212 | h1 {{ color: #58a6ff; border-bottom: 1px solid #30363d; padding-bottom: 0.5rem; }} |
| 213 | .meta {{ color: #8b949e; font-size: 0.9rem; margin-bottom: 2rem; }} |
| 214 | pre {{ white-space: pre-wrap; word-wrap: break-word; margin: 0; }} |
| 215 | .footer {{ margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #30363d; color: #8b949e; font-size: 0.8rem; }} |
| 216 | </style> |
| 217 | </head> |
| 218 | <body> |
| 219 | <h1>codewhale Session</h1> |
| 220 | <div class="meta"> |
| 221 | <strong>Model:</strong> {escaped_model} · <strong>Mode:</strong> {escaped_mode}<br> |
| 222 | <strong>Exported:</strong> {timestamp} |
| 223 | </div> |
| 224 | <pre>{escaped_body}</pre> |
| 225 | <div class="footer"> |
| 226 | Generated by codewhale · https://github.com/codewhale-hq/CodeWhale |
| 227 | </div> |
| 228 | </body> |
| 229 | </html>"#, |
| 230 | ) |
| 231 | } |
| 232 | |
| 233 | /// HTML-escape special characters. |
| 234 | fn html_escape(s: &str) -> String { |
| 235 | s.replace('&', "&") |
| 236 | .replace('<', "<") |
| 237 | .replace('>', ">") |
| 238 | .replace('"', """) |
| 239 | .replace('\'', "'") |
| 240 | } |
| 241 | |
| 242 | /// Write HTML to a secure temp file and keep it alive for upload. |
| 243 | fn write_temp_html(html: &str) -> Result<tempfile::NamedTempFile, String> { |
| 244 | let mut tmp = tempfile::Builder::new() |
| 245 | .prefix("codewhale-share-") |
| 246 | .suffix(".html") |
| 247 | .tempfile() |
| 248 | .map_err(|e| format!("{e}"))?; |
| 249 | tmp.write_all(html.as_bytes()).map_err(|e| format!("{e}"))?; |
| 250 | Ok(tmp) |
| 251 | } |
| 252 | |
| 253 | /// Upload a file as a secret GitHub gist using the `gh` CLI. `gh gist |
| 254 | /// create` makes secret gists unless `--public` is passed; it never is here. |
| 255 | fn upload_gist(path: &Path) -> Result<String, String> { |
| 256 | let mut cmd = crate::dependencies::Gh::command() |
| 257 | .ok_or_else(|| "the `gh` CLI was not found".to_string())?; |
| 258 | let output = cmd |
| 259 | .args(gist_create_args(path)) |
| 260 | .output() |
| 261 | .map_err(|e| format!("Failed to run `gh gist create`: {e}"))?; |
| 262 | |
| 263 | if !output.status.success() { |
| 264 | let stderr = String::from_utf8_lossy(&output.stderr); |
| 265 | return Err(format!("`gh gist create` failed: {stderr}")); |
| 266 | } |
| 267 | |
| 268 | let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); |
| 269 | if stdout.is_empty() { |
| 270 | return Err("`gh gist create` returned no output".to_string()); |
| 271 | } |
| 272 | |
| 273 | Ok(stdout) |
| 274 | } |
| 275 | |
| 276 | fn gist_create_args(path: &Path) -> Vec<String> { |
| 277 | vec![ |
| 278 | "gist".to_string(), |
| 279 | "create".to_string(), |
| 280 | path.to_string_lossy().into_owned(), |
| 281 | "--filename".to_string(), |
| 282 | "session-export.html".to_string(), |
| 283 | "--desc".to_string(), |
| 284 | "codewhale Session Export".to_string(), |
| 285 | ] |
| 286 | } |
| 287 | |
| 288 | pub(in crate::commands) const SHARE_INFO: CommandInfo = CommandInfo { |
| 289 | name: "share", |
| 290 | aliases: &[], |
| 291 | usage: "/share [confirm]", |
| 292 | description_key: "cmd_share_description", |
| 293 | }; |
| 294 | |
| 295 | pub(in crate::commands) struct ShareCmd; |
| 296 | |
| 297 | impl RegisterCommand<CommandResult> for ShareCmd { |
| 298 | fn info() -> &'static CommandInfo { |
| 299 | &SHARE_INFO |
| 300 | } |
| 301 | |
| 302 | fn handler() -> CommandHandler<CommandResult> { |
| 303 | CommandHandler::Contextual { |
| 304 | capabilities: CommandCapabilities::SESSION_EXPORT, |
| 305 | handler: share_contextual, |
| 306 | } |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | /// Contextual `/share` dispatch. `/share` reads the conversation through the |
| 311 | /// session-export facet, the same authority `/export` uses. |
| 312 | fn share_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult { |
| 313 | let parts = contexts.into_parts(); |
| 314 | let Some(export) = parts.export.as_deref() else { |
| 315 | return CommandResult::error("Command capability unavailable: session_export"); |
| 316 | }; |
| 317 | share(export, arg) |
| 318 | } |
| 319 | |
| 320 | #[cfg(test)] |
| 321 | mod tests { |
| 322 | use super::*; |
| 323 | use codewhale_command_contract::facets::{ |
| 324 | ExportMessage, ExportMetadata, TurnHandoffProjection, |
| 325 | }; |
| 326 | use std::cell::Cell; |
| 327 | use std::path::PathBuf; |
| 328 | |
| 329 | const FAKE_KEY: &str = "sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD"; |
| 330 | |
| 331 | /// Fake export facet: serves one projection and fails loudly if `/share` |
| 332 | /// ever reaches for clipboard or filesystem effects. |
| 333 | struct FakeExport { |
| 334 | messages: Vec<ExportMessage>, |
| 335 | projections: Cell<usize>, |
| 336 | } |
| 337 | |
| 338 | impl FakeExport { |
| 339 | fn new(messages: Vec<ExportMessage>) -> Self { |
| 340 | Self { |
| 341 | messages, |
| 342 | projections: Cell::new(0), |
| 343 | } |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | impl CommandSessionExportContext for FakeExport { |
| 348 | fn conversation_projection(&self) -> ConversationExportProjection { |
| 349 | self.projections.set(self.projections.get() + 1); |
| 350 | ConversationExportProjection { |
| 351 | metadata: ExportMetadata { |
| 352 | session_label: "sess1234".to_string(), |
| 353 | provider: "deepseek".to_string(), |
| 354 | model: "deepseek-v4-pro".to_string(), |
| 355 | mode: "agent".to_string(), |
| 356 | workspace_name: "workspace".to_string(), |
| 357 | message_count: self.messages.len(), |
| 358 | exported_at_unix: 1_700_000_000, |
| 359 | }, |
| 360 | transcript: TranscriptProjection::Authoritative(self.messages.clone()), |
| 361 | restore_points: RestorePointProjection::Unreadable { |
| 362 | reason: "local-only snapshot detail".to_string(), |
| 363 | }, |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | fn turn_handoff_projection(&self) -> TurnHandoffProjection { |
| 368 | panic!("/share must not read the turn handoff"); |
| 369 | } |
| 370 | |
| 371 | fn clipboard_requires_terminal_paste(&self) -> bool { |
| 372 | panic!("/share must not touch the clipboard"); |
| 373 | } |
| 374 | |
| 375 | fn write_recovery_copy(&self, _markdown: &str) -> Option<PathBuf> { |
| 376 | panic!("/share must not write a recovery copy"); |
| 377 | } |
| 378 | |
| 379 | fn write_clipboard(&self, _markdown: &str) -> Result<(), String> { |
| 380 | panic!("/share must not touch the clipboard"); |
| 381 | } |
| 382 | |
| 383 | fn resolve_export_path(&self, _raw: &str) -> Result<PathBuf, String> { |
| 384 | panic!("/share must not resolve export paths"); |
| 385 | } |
| 386 | |
| 387 | fn write_export_file( |
| 388 | &self, |
| 389 | _path: &Path, |
| 390 | _contents: &[u8], |
| 391 | _force: bool, |
| 392 | ) -> Result<(), String> { |
| 393 | panic!("/share must not write export files"); |
| 394 | } |
| 395 | } |
| 396 | |
| 397 | fn message(role: &str, blocks: Vec<ExportBlock>) -> ExportMessage { |
| 398 | ExportMessage { |
| 399 | role: role.to_string(), |
| 400 | is_user_role: role == "user", |
| 401 | blocks, |
| 402 | prompt_snippet: None, |
| 403 | } |
| 404 | } |
| 405 | |
| 406 | fn session_with_secret_tool_result() -> FakeExport { |
| 407 | FakeExport::new(vec![ |
| 408 | message( |
| 409 | "system", |
| 410 | vec![ExportBlock::Text { |
| 411 | text: "HIDDEN SYSTEM PROMPT".to_string(), |
| 412 | }], |
| 413 | ), |
| 414 | message( |
| 415 | "user", |
| 416 | vec![ExportBlock::Text { |
| 417 | text: "print my env".to_string(), |
| 418 | }], |
| 419 | ), |
| 420 | message( |
| 421 | "assistant", |
| 422 | vec![ |
| 423 | ExportBlock::InternalReasoning, |
| 424 | ExportBlock::ToolCall { |
| 425 | id: "call_1".to_string(), |
| 426 | name: "exec_shell".to_string(), |
| 427 | caller: None, |
| 428 | input: serde_json::json!({"command": "env"}), |
| 429 | }, |
| 430 | ], |
| 431 | ), |
| 432 | message( |
| 433 | "user", |
| 434 | vec![ExportBlock::ToolResult { |
| 435 | tool_use_id: "call_1".to_string(), |
| 436 | content: format!("PATH=/usr/bin\nOPENAI_API_KEY={FAKE_KEY}\nleaked {FAKE_KEY}"), |
| 437 | is_error: false, |
| 438 | structured: None, |
| 439 | }], |
| 440 | ), |
| 441 | ]) |
| 442 | } |
| 443 | |
| 444 | fn share_html(result: &CommandResult) -> &str { |
| 445 | match result.action.as_ref() { |
| 446 | Some(AppAction::ShareSession { html }) => html, |
| 447 | other => panic!("expected ShareSession, got {other:?}"), |
| 448 | } |
| 449 | } |
| 450 | |
| 451 | #[test] |
| 452 | fn plain_share_previews_without_an_upload_action() { |
| 453 | let export = session_with_secret_tool_result(); |
| 454 | let result = share(&export, Some("")); |
| 455 | assert!(!result.is_error, "{result:?}"); |
| 456 | assert!( |
| 457 | result.action.is_none(), |
| 458 | "plain /share must not start an upload: {:?}", |
| 459 | result.action |
| 460 | ); |
| 461 | let msg = result.message.unwrap(); |
| 462 | assert!(msg.contains("nothing has been uploaded"), "{msg}"); |
| 463 | assert!(msg.contains("Messages: 4"), "{msg}"); |
| 464 | assert!( |
| 465 | msg.contains("Tool output: included (1 tool result(s), redacted)"), |
| 466 | "{msg}" |
| 467 | ); |
| 468 | assert!(msg.contains("Redacted: 2 item(s)"), "{msg}"); |
| 469 | assert!(msg.contains("/share confirm"), "{msg}"); |
| 470 | assert!(msg.contains("unlisted, not private"), "{msg}"); |
| 471 | assert!(!msg.contains(FAKE_KEY), "{msg}"); |
| 472 | } |
| 473 | |
| 474 | #[test] |
| 475 | fn confirm_renders_the_redacted_export_projection_not_raw_messages() { |
| 476 | let export = session_with_secret_tool_result(); |
| 477 | let result = share(&export, Some("confirm")); |
| 478 | assert!(!result.is_error, "{result:?}"); |
| 479 | assert!( |
| 480 | result |
| 481 | .message |
| 482 | .as_deref() |
| 483 | .unwrap() |
| 484 | .contains("unlisted, not private"), |
| 485 | "{result:?}" |
| 486 | ); |
| 487 | let msg = result.message.as_deref().unwrap(); |
| 488 | assert!(msg.contains("Messages: 4"), "{msg}"); |
| 489 | assert!( |
| 490 | msg.contains("Tool output: included (1 tool result(s), redacted)"), |
| 491 | "{msg}" |
| 492 | ); |
| 493 | assert!(msg.contains("Redacted: 2 item(s)"), "{msg}"); |
| 494 | let html = share_html(&result); |
| 495 | assert!(!html.contains(FAKE_KEY), "secret leaked into the page"); |
| 496 | assert!( |
| 497 | !html.contains("sk-proj-"), |
| 498 | "secret prefix leaked into the page" |
| 499 | ); |
| 500 | assert!(!html.contains("HIDDEN SYSTEM PROMPT"), "system text leaked"); |
| 501 | assert!(html.contains("[internal context omitted]")); |
| 502 | assert!(html.contains("[internal reasoning and signature omitted]")); |
| 503 | assert!( |
| 504 | html.contains("PATH=/usr/bin"), |
| 505 | "ordinary tool output is kept" |
| 506 | ); |
| 507 | assert!( |
| 508 | !html.contains("local-only snapshot detail"), |
| 509 | "restore-point table is local-only" |
| 510 | ); |
| 511 | assert!(html.contains("deepseek-v4-pro")); |
| 512 | assert_eq!(export.projections.get(), 1); |
| 513 | } |
| 514 | |
| 515 | #[test] |
| 516 | fn empty_session_errors_for_preview_and_confirm() { |
| 517 | let export = FakeExport::new(Vec::new()); |
| 518 | for arg in ["", "confirm"] { |
| 519 | let result = share(&export, Some(arg)); |
| 520 | assert!(result.is_error, "{arg}: {result:?}"); |
| 521 | assert!(result.message.unwrap().contains("Nothing to share")); |
| 522 | assert!(result.action.is_none()); |
| 523 | } |
| 524 | } |
| 525 | |
| 526 | #[test] |
| 527 | fn help_and_unknown_routes() { |
| 528 | let export = session_with_secret_tool_result(); |
| 529 | for arg in ["help", "--help", "-h"] { |
| 530 | let result = share(&export, Some(arg)); |
| 531 | assert!(!result.is_error); |
| 532 | assert!(result.action.is_none()); |
| 533 | let msg = result.message.unwrap(); |
| 534 | assert!(msg.contains("/share confirm"), "{msg}"); |
| 535 | assert!(msg.contains("unlisted, not private"), "{msg}"); |
| 536 | } |
| 537 | let result = share(&export, Some("bogus")); |
| 538 | assert!(result.is_error); |
| 539 | assert!( |
| 540 | result |
| 541 | .message |
| 542 | .unwrap() |
| 543 | .contains("Unknown /share argument `bogus`") |
| 544 | ); |
| 545 | assert_eq!(export.projections.get(), 0, "help/unknown read nothing"); |
| 546 | } |
| 547 | |
| 548 | #[test] |
| 549 | fn missing_export_facet_fails_safely() { |
| 550 | let result = share_contextual(CommandContexts::empty(), Some("confirm")); |
| 551 | assert!(result.is_error); |
| 552 | assert!(result.action.is_none()); |
| 553 | assert!( |
| 554 | result |
| 555 | .message |
| 556 | .unwrap() |
| 557 | .contains("Command capability unavailable: session_export") |
| 558 | ); |
| 559 | } |
| 560 | |
| 561 | #[test] |
| 562 | fn gist_is_created_without_public_flag() { |
| 563 | let args = gist_create_args(Path::new("/tmp/page.html")); |
| 564 | assert!(!args.iter().any(|arg| arg == "--public"), "{args:?}"); |
| 565 | assert_eq!(&args[..3], ["gist", "create", "/tmp/page.html"]); |
| 566 | } |
| 567 | |
| 568 | #[test] |
| 569 | fn temp_page_is_removed_after_upload_success_and_failure() { |
| 570 | let mut seen = None; |
| 571 | let url = upload_via_temp_file("<html>ok</html>", |path| { |
| 572 | assert_eq!(std::fs::read_to_string(path).unwrap(), "<html>ok</html>"); |
| 573 | seen = Some(path.to_path_buf()); |
| 574 | Ok("https://gist.github.com/x".to_string()) |
| 575 | }) |
| 576 | .unwrap(); |
| 577 | assert_eq!(url, "https://gist.github.com/x"); |
| 578 | assert!( |
| 579 | !seen.unwrap().exists(), |
| 580 | "temp page must be deleted after upload" |
| 581 | ); |
| 582 | |
| 583 | let mut seen = None; |
| 584 | let err = upload_via_temp_file("<html>no</html>", |path| { |
| 585 | seen = Some(path.to_path_buf()); |
| 586 | Err("gh failed".to_string()) |
| 587 | }) |
| 588 | .unwrap_err(); |
| 589 | assert_eq!(err, "gh failed"); |
| 590 | assert!( |
| 591 | !seen.unwrap().exists(), |
| 592 | "temp page must be deleted on failure" |
| 593 | ); |
| 594 | } |
| 595 | |
| 596 | #[test] |
| 597 | fn html_escapes_the_rendered_markdown() { |
| 598 | let html = render_session_html("<script>x</script> & \"q\"", "m<1>", "agent"); |
| 599 | assert!(html.contains("<script>x</script> & "q"")); |
| 600 | assert!(html.contains("m<1>")); |
| 601 | assert!(!html.contains("<script>")); |
| 602 | } |
| 603 | } |
| 604 |