1use std::collections::HashSet;
2use std::ffi::OsStr;
3use std::fs;
4use std::io::{Read, Write};
5use std::net::{TcpListener, TcpStream};
6use std::path::{Component, Path, PathBuf};
7use std::process::Command;
8use std::sync::Arc;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::thread;
11use std::time::{Duration, UNIX_EPOCH};
12
13use chrono::Utc;
14use serde::Serialize;
15use sha2::{Digest, Sha256};
16
17use crate::analysis::Analyzer;
18use crate::analysis::triage::TriageOptions;
19use crate::export_sqlite::{
20 SQLITE_EXPORT_CONFIG_FILENAME, SQLITE_EXPORT_FILENAME, SqliteBootstrapOptions,
21 SqliteBundleOptions, bootstrap_export_database, emit_bootstrap_config,
22 populate_export_database,
23};
24use crate::model::Issue;
25use crate::{BvrError, Result};
26
27const DEFAULT_PAGES_TITLE: &str = "Project Issues";
28const DEFAULT_PREVIEW_PORT: u16 = 9000;
29const MAX_PREVIEW_PORT_ATTEMPTS: u16 = 32;
30const PREVIEW_MAX_REQUESTS_ENV: &str = "BVR_PREVIEW_MAX_REQUESTS";
31const PREVIEW_ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(50);
32const PREVIEW_STATUS_PATH: &str = "/__preview__/status";
33const PREVIEW_RELOAD_PATH: &str = "/.bvr/livereload";
34
35#[cfg(unix)]
36const PREVIEW_SIGNAL_SET: &[i32] = &[signal_hook::consts::SIGINT, signal_hook::consts::SIGTERM];
37
38#[cfg(not(unix))]
39const PREVIEW_SIGNAL_SET: &[i32] = &[signal_hook::consts::SIGINT];
40
41const STATIC_HOST_HEADERS: &str = "\
42/*
43 Cross-Origin-Embedder-Policy: require-corp
44 Cross-Origin-Opener-Policy: same-origin
45 Cache-Control: public, max-age=3600
46 X-Content-Type-Options: nosniff
47
48/*.wasm
49 Content-Type: application/wasm
50 Cache-Control: public, max-age=86400
51
52/*.json
53 Content-Type: application/json; charset=utf-8
54 Cache-Control: no-cache
55
56/beads.sqlite3
57 Content-Type: application/x-sqlite3
58 Cache-Control: public, max-age=3600
59";
60
61const LIVE_RELOAD_SCRIPT: &str = r"<script>
62(() => {
63 let lastToken = null;
64 async function poll() {
65 try {
66 const resp = await fetch('/.bvr/livereload', { cache: 'no-store' });
67 const token = (await resp.text()).trim();
68 if (lastToken === null) {
69 lastToken = token;
70 } else if (token !== lastToken) {
71 window.location.reload();
72 return;
73 }
74 } catch (_) {}
75 setTimeout(poll, 1200);
76 }
77 poll();
78})();
79</script>";
80
81#[derive(Debug, Clone)]
82pub struct ExportPagesOptions {
83 pub title: Option<String>,
84 pub subtitle: Option<String>,
85 pub include_closed: bool,
86 pub include_history: bool,
87}
88
89#[derive(Debug, Clone, Serialize)]
90pub struct ExportPagesSummary {
91 pub export_path: String,
92 pub generated_at: String,
93 pub issue_count: usize,
94 pub include_closed: bool,
95 pub include_history: bool,
96 pub files: Vec<String>,
97}
98
99#[derive(Debug, Clone, Serialize)]
100struct PagesMeta {
101 title: String,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 subtitle: Option<String>,
104 generated_at: String,
105 issue_count: usize,
106 include_closed: bool,
107 include_history: bool,
108 generator: String,
109 version: String,
110}
111
112#[derive(Debug, Clone, Serialize)]
113struct PreviewStatusResponse {
114 status: &'static str,
115 port: u16,
116 url: String,
117 bundle_path: String,
118 has_index: bool,
119 file_count: usize,
120 live_reload: bool,
121 reload_mode: &'static str,
122 status_url: String,
123 reload_endpoint: Option<&'static str>,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127enum PreviewReloadMode {
128 Poll,
129 Disabled,
130}
131
132impl PreviewReloadMode {
133 const fn from_enabled(live_reload: bool) -> Self {
134 if live_reload {
135 Self::Poll
136 } else {
137 Self::Disabled
138 }
139 }
140
141 const fn label(self) -> &'static str {
142 match self {
143 Self::Poll => "poll",
144 Self::Disabled => "disabled",
145 }
146 }
147
148 const fn operator_summary(self) -> &'static str {
149 match self {
150 Self::Poll => "polling (GET /.bvr/livereload)",
151 Self::Disabled => "disabled",
152 }
153 }
154
155 const fn reload_endpoint(self) -> Option<&'static str> {
156 match self {
157 Self::Poll => Some(PREVIEW_RELOAD_PATH),
158 Self::Disabled => None,
159 }
160 }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164enum PreviewShutdownReason {
165 RequestLimitReached,
166 ShutdownSignal,
167}
168
169impl PreviewShutdownReason {
170 const fn operator_summary(self) -> &'static str {
171 match self {
172 Self::RequestLimitReached => "request limit reached",
173 Self::ShutdownSignal => "received shutdown signal",
174 }
175 }
176}
177
178pub fn print_pages_wizard() {
179 use crate::pages_wizard::{DeployTarget, WizardStep};
180
181 println!("bvr pages wizard");
182 println!();
183 println!("Steps:");
184 for step in WizardStep::ALL {
185 println!(" {}. {}", step.display_number(), step.label());
186 }
187 println!();
188 println!("Deploy targets:");
189 for target in DeployTarget::ALL {
190 let tools = target.required_tools();
191 if tools.is_empty() {
192 println!(" - {}", target.label());
193 } else {
194 println!(" - {} (requires: {})", target.label(), tools.join(", "));
195 }
196 }
197 println!();
198 println!("Non-interactive flow:");
199 println!(" 1) Export bundle: bvr --export-pages ./bv-pages");
200 println!(" 2) Preview bundle: bvr --preview-pages ./bv-pages");
201 println!(" 3) Optional watch: bvr --export-pages ./bv-pages --watch-export");
202 println!(" 4) Deploy ./bv-pages to your chosen static host");
203 println!();
204 println!("Tip: customize title and payload scope:");
205 println!(" bvr --export-pages ./bv-pages --pages-title \"Sprint Dashboard\" \\");
206 println!(" --pages-include-closed=false --pages-include-history=false");
207}
208
209pub fn export_pages_bundle(
210 issues: &[Issue],
211 output_dir: &Path,
212 options: &ExportPagesOptions,
213) -> Result<ExportPagesSummary> {
214 let title = options
215 .title
216 .as_deref()
217 .map(str::trim)
218 .filter(|value| !value.is_empty())
219 .unwrap_or(DEFAULT_PAGES_TITLE)
220 .to_string();
221 let subtitle = options
222 .subtitle
223 .as_deref()
224 .map(str::trim)
225 .filter(|value| !value.is_empty())
226 .map(std::string::ToString::to_string);
227
228 let filtered = issues
229 .iter()
230 .filter(|issue| options.include_closed || issue.is_open_like())
231 .cloned()
232 .collect::<Vec<_>>();
233
234 if let Some(parent) = output_dir.parent() {
236 if !parent.as_os_str().is_empty() && !parent.exists() {
237 fs::create_dir_all(parent).map_err(|error| {
238 BvrError::InvalidArgument(format!(
239 "cannot create export directory {}: {error}",
240 output_dir.display()
241 ))
242 })?;
243 }
244 }
245 fs::create_dir_all(output_dir.join("data")).map_err(|error| {
246 BvrError::InvalidArgument(format!(
247 "cannot prepare export directory {}: {error}",
248 output_dir.display()
249 ))
250 })?;
251
252 let analyzer = Analyzer::new(filtered.clone());
253 let triage = analyzer.triage(TriageOptions {
254 group_by_track: false,
255 group_by_label: false,
256 max_recommendations: 50,
257 ..TriageOptions::default()
258 });
259 let insights = analyzer.insights();
260
261 let generated_at = Utc::now().to_rfc3339();
262 let meta = PagesMeta {
263 title: title.clone(),
264 subtitle: subtitle.clone(),
265 generated_at: generated_at.clone(),
266 issue_count: filtered.len(),
267 include_closed: options.include_closed,
268 include_history: options.include_history,
269 generator: "bvr".to_string(),
270 version: env!("CARGO_PKG_VERSION").to_string(),
271 };
272
273 let mut files = Vec::<String>::new();
274
275 let asset_paths = crate::viewer_assets::write_viewer_assets(output_dir)?;
277 files.extend(asset_paths);
278
279 fs::create_dir_all(output_dir.join("assets"))?;
283 write_text(output_dir.join("assets/style.css"), CSS_BUNDLE)?;
284 files.push("assets/style.css".to_string());
285
286 write_text(output_dir.join("assets/viewer.js"), JS_BUNDLE)?;
287 files.push("assets/viewer.js".to_string());
288
289 write_json(output_dir.join("data/issues.json"), &filtered)?;
290 files.push("data/issues.json".to_string());
291
292 write_json(output_dir.join("data/meta.json"), &meta)?;
293 files.push("data/meta.json".to_string());
294
295 write_json(output_dir.join("data/triage.json"), &triage.result)?;
296 files.push("data/triage.json".to_string());
297
298 write_json(output_dir.join("data/insights.json"), &insights)?;
299 files.push("data/insights.json".to_string());
300
301 bootstrap_export_database(output_dir, &SqliteBootstrapOptions::default())?;
302 populate_export_database(output_dir, Some(&title), &filtered, &analyzer, &triage)?;
303 files.push(SQLITE_EXPORT_FILENAME.to_string());
304
305 let sqlite_config = emit_bootstrap_config(output_dir, &SqliteBundleOptions::default())?;
306 files.push(SQLITE_EXPORT_CONFIG_FILENAME.to_string());
307 for chunk in &sqlite_config.chunks {
308 files.push(chunk.path.clone());
309 }
310
311 let history_path = output_dir.join("data/history.json");
312 if options.include_history {
313 let history_limit = filtered.len().max(500);
314 let histories = analyzer.history(None, history_limit);
315 let history_compat: std::collections::BTreeMap<String, _> = histories
318 .iter()
319 .map(|h| {
320 (
321 h.id.clone(),
322 crate::analysis::git_history::HistoryBeadCompat {
323 bead_id: h.id.clone(),
324 title: h.title.clone(),
325 status: h.status.clone(),
326 events: h
327 .events
328 .iter()
329 .map(|e| crate::analysis::git_history::HistoryEventCompat {
330 bead_id: h.id.clone(),
331 event_type: e.kind.clone(),
332 timestamp: e
333 .timestamp
334 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
335 .unwrap_or_default(),
336 commit_sha: String::new(),
337 commit_message: e.details.clone(),
338 author: String::new(),
339 author_email: String::new(),
340 })
341 .collect(),
342 milestones: crate::analysis::git_history::HistoryMilestonesCompat::default(
343 ),
344 commits: None,
345 cycle_time: None,
346 last_author: String::new(),
347 },
348 )
349 })
350 .collect();
351 write_json(history_path.clone(), &history_compat)?;
352 files.push("data/history.json".to_string());
353 } else if history_path.exists() {
354 fs::remove_file(&history_path)?;
355 }
356
357 write_text(
359 output_dir.join("README.md"),
360 &generate_deploy_readme(&title, &meta),
361 )?;
362 files.push("README.md".to_string());
363
364 write_text(output_dir.join("_headers"), STATIC_HOST_HEADERS)?;
366 files.push("_headers".to_string());
367
368 files.push("data/export_summary.json".to_string());
369
370 let summary = ExportPagesSummary {
371 export_path: output_dir.to_string_lossy().to_string(),
372 generated_at,
373 issue_count: filtered.len(),
374 include_closed: options.include_closed,
375 include_history: options.include_history,
376 files,
377 };
378
379 write_json(output_dir.join("data/export_summary.json"), &summary)?;
380
381 Ok(summary)
382}
383
384fn generate_deploy_readme(title: &str, meta: &PagesMeta) -> String {
385 let subtitle_line = meta
386 .subtitle
387 .as_deref()
388 .map(|subtitle| format!("- **Subtitle**: {subtitle}\n"))
389 .unwrap_or_default();
390 format!(
391 "# {title}\n\
392 \n\
393 Static issue viewer bundle generated by **bvr** v{version}.\n\
394 \n\
395 ## Quick start\n\
396 \n\
397 Deploy this directory to any static host:\n\
398 \n\
399 - **GitHub Pages**: push this folder to your `gh-pages` branch\n\
400 - **Cloudflare Pages**: point your project at this folder\n\
401 - **Local preview**: `bvr --preview-pages {path}`\n\
402 \n\
403 ## Contents\n\
404 \n\
405 | File | Purpose |\n\
406 |------|---------|\n\
407 | `index.html` | Viewer entry point |\n\
408 | `data/` | JSON + SQLite data payloads |\n\
409 | `beads.sqlite3` | Full issue database |\n\
410 | `_headers` | Static-host header hints |\n\
411 \n\
412 ## Generation info\n\
413 \n\
414 - **Title**: {title}\n\
415 {subtitle_line}\
416 - **Issues**: {count}\n\
417 - **Generated**: {at}\n\
418 - **Generator**: bvr v{version}\n",
419 subtitle_line = subtitle_line,
420 version = meta.version,
421 count = meta.issue_count,
422 at = meta.generated_at,
423 path = ".",
424 )
425}
426
427pub fn run_preview_server(bundle_dir: &Path, live_reload: bool) -> Result<()> {
428 if !bundle_dir.is_dir() {
429 return Err(BvrError::InvalidArgument(format!(
430 "preview bundle directory not found: {} (run --export-pages {} first)",
431 bundle_dir.display(),
432 bundle_dir.display()
433 )));
434 }
435 if !bundle_dir.join("index.html").is_file() {
436 return Err(BvrError::InvalidArgument(format!(
437 "missing index.html in preview bundle: {} (run --export-pages {} to regenerate)",
438 bundle_dir.display(),
439 bundle_dir.display()
440 )));
441 }
442
443 let (listener, port) = bind_preview_listener()?;
444 listener.set_nonblocking(true)?;
445 let preview_url = preview_url(port);
446 let reload_mode = PreviewReloadMode::from_enabled(live_reload);
447 let shutdown_requested = install_preview_signal_handlers()?;
448
449 println!("Preview server running at {preview_url}");
450 println!("Serving bundle: {}", bundle_dir.display());
451 println!("Status endpoint: {preview_url}{PREVIEW_STATUS_PATH}");
452 println!("Reload transport: {}", reload_mode.operator_summary());
453 println!("Press Ctrl+C to stop.");
454 maybe_open_preview_in_browser(port);
455
456 let max_requests = std::env::var(PREVIEW_MAX_REQUESTS_ENV)
457 .ok()
458 .and_then(|raw| raw.trim().parse::<usize>().ok())
459 .filter(|value| *value > 0);
460 let mut served = 0usize;
461
462 let shutdown_reason = loop {
463 if shutdown_requested.load(Ordering::Relaxed) {
464 break PreviewShutdownReason::ShutdownSignal;
465 }
466
467 match listener.accept() {
468 Ok((stream, _)) => {
469 match handle_preview_request(stream, bundle_dir, live_reload, port) {
470 Ok(count_as_request) => {
471 if count_as_request {
472 served = served.saturating_add(1);
473 if max_requests.is_some_and(|limit| served >= limit) {
474 break PreviewShutdownReason::RequestLimitReached;
475 }
476 }
477 }
478 Err(error) => {
479 eprintln!("warning: preview request failed: {error}");
480 }
481 }
482 }
483 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
484 thread::sleep(PREVIEW_ACCEPT_POLL_INTERVAL);
485 }
486 Err(error) if shutdown_requested.load(Ordering::Relaxed) => {
487 eprintln!("warning: preview accept loop stopped after shutdown signal: {error}");
488 break PreviewShutdownReason::ShutdownSignal;
489 }
490 Err(error) => return Err(BvrError::Io(error)),
491 }
492 };
493
494 println!(
495 "Preview server stopped: {}.",
496 shutdown_reason.operator_summary()
497 );
498 Ok(())
499}
500
501fn bind_preview_listener() -> Result<(TcpListener, u16)> {
502 let base_port = std::env::var("BVR_PREVIEW_PORT")
503 .ok()
504 .and_then(|raw| raw.trim().parse::<u16>().ok())
505 .unwrap_or(DEFAULT_PREVIEW_PORT);
506
507 for offset in 0..MAX_PREVIEW_PORT_ATTEMPTS {
508 let port = base_port.saturating_add(offset);
509 match TcpListener::bind(("127.0.0.1", port)) {
510 Ok(listener) => return Ok((listener, port)),
511 Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => {}
512 Err(error) => {
513 return Err(BvrError::InvalidArgument(format!(
514 "failed to bind preview server on 127.0.0.1:{port}: {error}. Set BVR_PREVIEW_PORT to a free port or stop the conflicting process."
515 )));
516 }
517 }
518 }
519
520 Err(BvrError::InvalidArgument(format!(
521 "could not bind preview server on ports {base_port}..{}. Set BVR_PREVIEW_PORT to a free port or stop the conflicting process.",
522 base_port.saturating_add(MAX_PREVIEW_PORT_ATTEMPTS.saturating_sub(1))
523 )))
524}
525
526fn handle_preview_request(
527 mut stream: TcpStream,
528 bundle_dir: &Path,
529 live_reload: bool,
530 port: u16,
531) -> Result<bool> {
532 stream.set_read_timeout(Some(Duration::from_secs(5)))?;
533 stream.set_write_timeout(Some(Duration::from_secs(10)))?;
534 let mut buffer = [0_u8; 8192];
535 let bytes = stream.read(&mut buffer)?;
536 if bytes == 0 {
537 return Ok(false);
538 }
539
540 let request = String::from_utf8_lossy(&buffer[..bytes]);
541 let request_line = request.lines().next().unwrap_or_default();
542 let mut parts = request_line.split_whitespace();
543 let method = parts.next().unwrap_or_default();
544 let target = parts.next().unwrap_or("/");
545 let head_only = method == "HEAD";
546
547 if method != "GET" && method != "HEAD" {
548 write_http_response(
549 &mut stream,
550 "405 Method Not Allowed",
551 "text/plain; charset=utf-8",
552 b"method not allowed\n",
553 head_only,
554 )?;
555 return Ok(true);
556 }
557
558 let route = target.split('?').next().unwrap_or("/");
559 if route == PREVIEW_STATUS_PATH || route == "/.bvr/status" {
560 let payload = serde_json::to_vec(&preview_status(bundle_dir, live_reload, port)?)?;
561 write_http_response(
562 &mut stream,
563 "200 OK",
564 "application/json; charset=utf-8",
565 &payload,
566 head_only,
567 )?;
568 return Ok(true);
569 }
570
571 if route == PREVIEW_RELOAD_PATH {
572 if !live_reload {
573 write_http_response(
574 &mut stream,
575 "404 Not Found",
576 "text/plain; charset=utf-8",
577 b"not found\n",
578 head_only,
579 )?;
580 return Ok(true);
581 }
582
583 let token = latest_modified_token(bundle_dir)?;
584 write_http_response(
585 &mut stream,
586 "200 OK",
587 "text/plain; charset=utf-8",
588 token.as_bytes(),
589 head_only,
590 )?;
591 return Ok(true);
592 }
593
594 let Ok(relative) = normalize_request_path(route) else {
595 write_http_response(
596 &mut stream,
597 "400 Bad Request",
598 "text/plain; charset=utf-8",
599 b"invalid path\n",
600 head_only,
601 )?;
602 return Ok(true);
603 };
604
605 let Some(file_path) = resolve_preview_asset_path(bundle_dir, &relative)? else {
606 write_http_response(
607 &mut stream,
608 "404 Not Found",
609 "text/plain; charset=utf-8",
610 b"not found\n",
611 head_only,
612 )?;
613 return Ok(true);
614 };
615
616 let mut body = fs::read(&file_path)?;
617 let mime = mime_type_for_path(&file_path);
618 if live_reload && mime.starts_with("text/html") {
619 body = inject_live_reload(body);
620 }
621
622 write_http_response(&mut stream, "200 OK", mime, &body, head_only)?;
623 Ok(true)
624}
625
626fn normalize_request_path(route: &str) -> Result<PathBuf> {
627 let mut normalized = route.trim().trim_start_matches('/').to_string();
628 if normalized.is_empty() || normalized.ends_with('/') {
629 normalized.push_str("index.html");
630 }
631
632 let path = PathBuf::from(normalized);
633 for component in path.components() {
634 match component {
635 Component::Normal(_) | Component::CurDir => {}
636 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
637 return Err(BvrError::InvalidArgument(
638 "path traversal is not allowed".to_string(),
639 ));
640 }
641 }
642 }
643
644 Ok(path)
645}
646
647fn mime_type_for_path(path: &Path) -> &'static str {
648 match path.extension().and_then(OsStr::to_str).unwrap_or_default() {
649 "html" => "text/html; charset=utf-8",
650 "css" => "text/css; charset=utf-8",
651 "js" => "application/javascript; charset=utf-8",
652 "json" => "application/json; charset=utf-8",
653 "wasm" => "application/wasm",
654 "svg" => "image/svg+xml",
655 "png" => "image/png",
656 "jpg" | "jpeg" => "image/jpeg",
657 "woff2" => "font/woff2",
658 _ if path
659 .file_name()
660 .and_then(OsStr::to_str)
661 .is_some_and(|name| name.eq_ignore_ascii_case(SQLITE_EXPORT_FILENAME)) =>
662 {
663 "application/x-sqlite3"
664 }
665 _ => "application/octet-stream",
666 }
667}
668
669fn write_http_response(
670 stream: &mut TcpStream,
671 status: &str,
672 content_type: &str,
673 body: &[u8],
674 head_only: bool,
675) -> Result<()> {
676 let headers = format!(
677 "HTTP/1.1 {status}\r\n\
678 Content-Type: {content_type}\r\n\
679 Content-Length: {}\r\n\
680 Cache-Control: no-store, no-cache, must-revalidate, max-age=0\r\n\
681 Pragma: no-cache\r\n\
682 Expires: 0\r\n\
683 Connection: close\r\n\r\n",
684 body.len()
685 );
686 stream.write_all(headers.as_bytes())?;
687 if !head_only {
688 stream.write_all(body)?;
689 }
690 stream.flush()?;
691 Ok(())
692}
693
694fn inject_live_reload(html: Vec<u8>) -> Vec<u8> {
695 let html_text = String::from_utf8_lossy(&html);
696 let injected = html_text.rfind("</body>").map_or_else(
697 || {
698 let mut output = String::with_capacity(html_text.len() + LIVE_RELOAD_SCRIPT.len());
699 output.push_str(&html_text);
700 output.push_str(LIVE_RELOAD_SCRIPT);
701 output
702 },
703 |pos| {
704 let mut output = String::with_capacity(html_text.len() + LIVE_RELOAD_SCRIPT.len() + 8);
705 output.push_str(&html_text[..pos]);
706 output.push_str(LIVE_RELOAD_SCRIPT);
707 output.push_str("</body>");
708 output.push_str(&html_text[pos + "</body>".len()..]);
709 output
710 },
711 );
712 injected.into_bytes()
713}
714
715fn preview_status(
716 bundle_dir: &Path,
717 live_reload: bool,
718 port: u16,
719) -> Result<PreviewStatusResponse> {
720 let preview_url = preview_url(port);
721 let reload_mode = PreviewReloadMode::from_enabled(live_reload);
722
723 Ok(PreviewStatusResponse {
724 status: "running",
725 port,
726 url: preview_url.clone(),
727 bundle_path: bundle_dir.to_string_lossy().to_string(),
728 has_index: bundle_dir.join("index.html").is_file(),
729 file_count: count_files_recursive(bundle_dir)?,
730 live_reload,
731 reload_mode: reload_mode.label(),
732 status_url: format!("{preview_url}{PREVIEW_STATUS_PATH}"),
733 reload_endpoint: reload_mode.reload_endpoint(),
734 })
735}
736
737fn preview_url(port: u16) -> String {
738 format!("http://127.0.0.1:{port}")
739}
740
741fn latest_modified_token(path: &Path) -> Result<String> {
742 let bundle_root = fs::canonicalize(path)?;
743 let mut visited_dirs = HashSet::new();
744 let mut hasher = Sha256::new();
745 fingerprint_bundle_recursive(
746 path,
747 &bundle_root,
748 Path::new(""),
749 &mut visited_dirs,
750 &mut hasher,
751 )?;
752 Ok(format!("{:x}", hasher.finalize()))
753}
754
755fn count_files_recursive(path: &Path) -> Result<usize> {
756 let bundle_root = fs::canonicalize(path)?;
757 let mut visited_dirs = HashSet::new();
758 count_files_recursive_inner(path, &bundle_root, &mut visited_dirs)
759}
760
761fn count_files_recursive_inner(
762 path: &Path,
763 bundle_root: &Path,
764 visited_dirs: &mut HashSet<PathBuf>,
765) -> Result<usize> {
766 let link_metadata = fs::symlink_metadata(path)?;
767 let resolved_path = match fs::canonicalize(path) {
768 Ok(resolved) => resolved,
769 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
770 Err(error) => return Err(BvrError::Io(error)),
771 };
772 if !resolved_path.starts_with(bundle_root) {
773 return Ok(0);
774 }
775
776 let target_metadata = match fs::metadata(&resolved_path) {
777 Ok(metadata) => metadata,
778 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
779 Err(error) => return Err(BvrError::Io(error)),
780 };
781
782 if target_metadata.is_file() {
783 return Ok(1);
784 }
785
786 if !target_metadata.is_dir() {
787 return Ok(0);
788 }
789
790 if !visited_dirs.insert(resolved_path) {
791 return Ok(0);
792 }
793
794 let mut total = 0usize;
795 for entry in fs::read_dir(path)? {
796 total = total.saturating_add(count_files_recursive_inner(
797 &entry?.path(),
798 bundle_root,
799 visited_dirs,
800 )?);
801 }
802
803 let _ = link_metadata;
804 Ok(total)
805}
806
807fn fingerprint_bundle_recursive(
808 path: &Path,
809 bundle_root: &Path,
810 relative: &Path,
811 visited_dirs: &mut HashSet<PathBuf>,
812 hasher: &mut Sha256,
813) -> Result<()> {
814 let link_metadata = fs::symlink_metadata(path)?;
815 let resolved_path = match fs::canonicalize(path) {
816 Ok(resolved) => resolved,
817 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
818 Err(error) => return Err(BvrError::Io(error)),
819 };
820 if !resolved_path.starts_with(bundle_root) {
821 return Ok(());
822 }
823
824 let target_metadata = match fs::metadata(&resolved_path) {
825 Ok(metadata) => metadata,
826 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
827 Err(error) => return Err(BvrError::Io(error)),
828 };
829
830 hasher.update(relative.to_string_lossy().as_bytes());
831 hasher.update([0]);
832 if link_metadata.file_type().is_symlink() {
833 hasher.update(b"symlink");
834 hasher.update([0]);
835 hasher.update(resolved_path.to_string_lossy().as_bytes());
836 hasher.update([0]);
837 }
838
839 if target_metadata.is_file() {
840 hasher.update(b"file");
841 hasher.update([0]);
842 hasher.update(fs::read(&resolved_path)?);
843 return Ok(());
844 }
845
846 if !target_metadata.is_dir() {
847 hasher.update(b"other");
848 hasher.update([0]);
849 hasher.update(metadata_modified_token(&target_metadata).to_le_bytes());
850 return Ok(());
851 }
852
853 hasher.update(b"dir");
854 hasher.update([0]);
855 if !visited_dirs.insert(resolved_path) {
856 hasher.update(b"visited");
857 hasher.update([0]);
858 return Ok(());
859 }
860
861 let mut entries = fs::read_dir(path)?.collect::<std::result::Result<Vec<_>, _>>()?;
862 entries.sort_by_key(|entry| entry.file_name());
863 for entry in entries {
864 let child_relative = if relative.as_os_str().is_empty() {
865 PathBuf::from(entry.file_name())
866 } else {
867 relative.join(entry.file_name())
868 };
869 fingerprint_bundle_recursive(
870 &entry.path(),
871 bundle_root,
872 &child_relative,
873 visited_dirs,
874 hasher,
875 )?;
876 }
877
878 Ok(())
879}
880
881fn metadata_modified_token(metadata: &fs::Metadata) -> u64 {
882 metadata
883 .modified()
884 .ok()
885 .and_then(|value| value.duration_since(UNIX_EPOCH).ok())
886 .map_or(0, |duration| {
887 u64::try_from(duration.as_millis().min(u128::from(u64::MAX))).unwrap_or(u64::MAX)
888 })
889}
890
891fn resolve_preview_asset_path(bundle_dir: &Path, relative: &Path) -> Result<Option<PathBuf>> {
892 let bundle_root = fs::canonicalize(bundle_dir)?;
893 let candidate = bundle_dir.join(relative);
894 let resolved = match fs::canonicalize(&candidate) {
895 Ok(path) => path,
896 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
897 Err(error) => return Err(BvrError::Io(error)),
898 };
899
900 if !resolved.starts_with(&bundle_root) {
901 return Ok(None);
902 }
903
904 let metadata = fs::metadata(&resolved)?;
905 if metadata.is_file() {
906 Ok(Some(resolved))
907 } else if metadata.is_dir() {
908 let index_path = resolved.join("index.html");
909 match fs::metadata(&index_path) {
910 Ok(index_metadata) if index_metadata.is_file() => Ok(Some(index_path)),
911 Ok(_) => Ok(None),
912 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
913 Err(error) => Err(BvrError::Io(error)),
914 }
915 } else {
916 Ok(None)
917 }
918}
919
920fn write_text(path: PathBuf, content: &str) -> Result<()> {
921 fs::write(path, content)?;
922 Ok(())
923}
924
925fn write_json<T: Serialize>(path: PathBuf, payload: &T) -> Result<()> {
926 let text = serde_json::to_string_pretty(payload)?;
927 fs::write(path, text)?;
928 Ok(())
929}
930
931fn maybe_open_preview_in_browser(port: u16) {
932 if std::env::var("BV_NO_BROWSER").is_ok() || std::env::var("BVR_NO_BROWSER").is_ok() {
933 return;
934 }
935
936 let url = preview_url(port);
937 thread::spawn(move || {
938 std::thread::sleep(std::time::Duration::from_millis(500));
939 if !open_url_in_browser(&url) {
940 eprintln!("warning: could not open browser automatically; open {url}");
941 }
942 });
943}
944
945fn open_url_in_browser(url: &str) -> bool {
946 if cfg!(target_os = "windows") {
947 run_command("cmd", &["/C", "start", "", url])
948 } else if cfg!(target_os = "macos") {
949 run_command("open", &[url])
950 } else {
951 run_command("xdg-open", &[url])
952 || run_command("open", &[url])
953 || run_command("gio", &["open", url])
954 }
955}
956
957fn run_command(command: &str, args: &[&str]) -> bool {
958 Command::new(command)
959 .args(args)
960 .stdout(std::process::Stdio::null())
961 .stderr(std::process::Stdio::null())
962 .status()
963 .is_ok_and(|status| status.success())
964}
965
966fn install_preview_signal_handlers() -> Result<Arc<AtomicBool>> {
967 let shutdown_requested = Arc::new(AtomicBool::new(false));
968 for signal in PREVIEW_SIGNAL_SET {
969 signal_hook::flag::register(*signal, Arc::clone(&shutdown_requested))?;
970 }
971 Ok(shutdown_requested)
972}
973
974const CSS_BUNDLE: &str = r":root {
975 color-scheme: light dark;
976 font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif;
977}
978body {
979 margin: 0;
980 background: #0b1220;
981 color: #dce6ff;
982}
983.layout {
984 max-width: 1100px;
985 margin: 0 auto;
986 padding: 1.2rem;
987}
988h1, h2 {
989 margin: 0 0 0.6rem 0;
990}
991.meta {
992 color: #9db0d7;
993}
994.grid {
995 display: grid;
996 gap: 1rem;
997 grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
998 margin-top: 1rem;
999}
1000article {
1001 background: #111b31;
1002 border: 1px solid #2b3a5a;
1003 border-radius: 10px;
1004 padding: 0.9rem;
1005}
1006.issue-list, .pick-list {
1007 margin: 0;
1008 padding-left: 1.2rem;
1009}
1010.issue-list li, .pick-list li {
1011 margin-bottom: 0.5rem;
1012}
1013.insights {
1014 white-space: pre-wrap;
1015 font-size: 0.82rem;
1016 margin: 0;
1017}
1018";
1019
1020const JS_BUNDLE: &str = r#"async function fetchJson(path) {
1021 const response = await fetch(path, { cache: "no-store" });
1022 if (!response.ok) {
1023 throw new Error(`failed to fetch ${path}: ${response.status}`);
1024 }
1025 return response.json();
1026}
1027
1028function formatIssue(issue) {
1029 return `${issue.id} · ${issue.status} · p${issue.priority} · ${issue.title}`;
1030}
1031
1032async function init() {
1033 const [meta, issues, triage, insights] = await Promise.all([
1034 fetchJson("data/meta.json"),
1035 fetchJson("data/issues.json"),
1036 fetchJson("data/triage.json"),
1037 fetchJson("data/insights.json")
1038 ]);
1039
1040 const metaLine = document.getElementById("meta-line");
1041 metaLine.textContent = `${meta.issue_count} issues · generated ${meta.generated_at}`;
1042
1043 const issueList = document.getElementById("issue-list");
1044 for (const issue of issues) {
1045 const li = document.createElement("li");
1046 li.textContent = formatIssue(issue);
1047 issueList.appendChild(li);
1048 }
1049
1050 const topPicks = document.getElementById("top-picks");
1051 for (const pick of (triage.quick_ref?.top_picks ?? [])) {
1052 const li = document.createElement("li");
1053 li.textContent = `${pick.id} (${(pick.score * 100).toFixed(1)}%)`;
1054 topPicks.appendChild(li);
1055 }
1056
1057 const insightsNode = document.getElementById("insights");
1058 const bottlenecks = (insights.Bottlenecks ?? []).slice(0, 5)
1059 .map((entry) => `${entry.id}: score=${entry.score.toFixed(3)} blocks=${entry.blocks_count}`);
1060 insightsNode.textContent = bottlenecks.length > 0
1061 ? bottlenecks.join("\n")
1062 : "No bottlenecks available.";
1063}
1064
1065init().catch((error) => {
1066 const metaLine = document.getElementById("meta-line");
1067 metaLine.textContent = `failed to load export data: ${error.message}`;
1068});
1069"#;
1070
1071#[cfg(test)]
1072mod tests {
1073 use super::*;
1074 use tempfile::tempdir;
1075
1076 fn make_issue(id: &str, status: &str) -> Issue {
1077 Issue {
1078 id: id.to_string(),
1079 title: format!("Issue {id}"),
1080 description: String::new(),
1081 design: String::new(),
1082 acceptance_criteria: String::new(),
1083 notes: String::new(),
1084 status: status.to_string(),
1085 priority: 2,
1086 issue_type: "task".to_string(),
1087 assignee: String::new(),
1088 estimated_minutes: Some(30),
1089 created_at: None,
1090 updated_at: None,
1091 due_date: None,
1092 closed_at: None,
1093 labels: Vec::new(),
1094 comments: Vec::new(),
1095 dependencies: Vec::new(),
1096 source_repo: ".".to_string(),
1097 workspace_prefix: None,
1098 content_hash: None,
1099 external_ref: None,
1100 }
1101 }
1102
1103 #[test]
1104 fn export_pages_bundle_writes_expected_core_files() {
1105 let temp = tempdir().expect("tempdir");
1106 let out = temp.path().join("pages");
1107 let issues = vec![make_issue("A", "open"), make_issue("B", "closed")];
1108
1109 let summary = export_pages_bundle(
1110 &issues,
1111 &out,
1112 &ExportPagesOptions {
1113 title: Some("Dashboard".to_string()),
1114 subtitle: None,
1115 include_closed: true,
1116 include_history: true,
1117 },
1118 )
1119 .expect("export pages bundle");
1120
1121 assert_eq!(summary.issue_count, 2);
1122 assert!(out.join("index.html").is_file());
1123 assert!(out.join("assets/style.css").is_file());
1124 assert!(out.join("assets/viewer.js").is_file());
1125 assert!(out.join("data/issues.json").is_file());
1126 assert!(out.join("data/meta.json").is_file());
1127 assert!(out.join("data/triage.json").is_file());
1128 assert!(out.join("data/insights.json").is_file());
1129 assert!(out.join("data/history.json").is_file());
1130 assert!(out.join("data/export_summary.json").is_file());
1131 assert!(out.join("beads.sqlite3").is_file());
1132 assert!(out.join("beads.sqlite3.config.json").is_file());
1133 assert!(
1134 summary
1135 .files
1136 .contains(&"data/export_summary.json".to_string())
1137 );
1138 assert!(summary.files.contains(&"beads.sqlite3".to_string()));
1139 assert!(
1140 summary
1141 .files
1142 .contains(&"beads.sqlite3.config.json".to_string())
1143 );
1144 }
1145
1146 #[test]
1147 fn export_pages_bundle_respects_include_closed_flag() {
1148 let temp = tempdir().expect("tempdir");
1149 let out = temp.path().join("pages");
1150 let issues = vec![make_issue("A", "open"), make_issue("B", "closed")];
1151
1152 let summary = export_pages_bundle(
1153 &issues,
1154 &out,
1155 &ExportPagesOptions {
1156 title: None,
1157 subtitle: None,
1158 include_closed: false,
1159 include_history: false,
1160 },
1161 )
1162 .expect("export pages bundle");
1163
1164 assert_eq!(summary.issue_count, 1);
1165 assert!(!out.join("data/history.json").exists());
1166
1167 let exported = fs::read_to_string(out.join("data/issues.json")).expect("read issues.json");
1168 assert!(exported.contains("\"A\""));
1169 assert!(!exported.contains("\"B\""));
1170 }
1171
1172 #[test]
1173 fn export_pages_bundle_writes_sqlite_bootstrap_config_with_hash() {
1174 let temp = tempdir().expect("tempdir");
1175 let out = temp.path().join("pages");
1176 let issues = vec![make_issue("A", "open")];
1177
1178 export_pages_bundle(
1179 &issues,
1180 &out,
1181 &ExportPagesOptions {
1182 title: None,
1183 subtitle: None,
1184 include_closed: false,
1185 include_history: false,
1186 },
1187 )
1188 .expect("export pages bundle");
1189
1190 let config: crate::export_sqlite::SqliteBootstrapConfig = serde_json::from_str(
1191 &fs::read_to_string(out.join("beads.sqlite3.config.json")).expect("read config"),
1192 )
1193 .expect("parse config");
1194
1195 assert!(!config.chunked);
1196 assert!(config.total_size > 0);
1197 assert_eq!(config.hash.len(), 64);
1198 }
1199
1200 #[test]
1201 fn normalize_request_path_rejects_parent_segments() {
1202 let result = normalize_request_path("/../../etc/passwd");
1203 assert!(result.is_err());
1204 }
1205
1206 #[test]
1207 fn inject_live_reload_appends_script() {
1208 let html = b"<html><body>ok</body></html>".to_vec();
1209 let injected = inject_live_reload(html);
1210 let text = String::from_utf8(injected).expect("utf8");
1211 assert!(text.contains("window.location.reload"));
1212 }
1213
1214 #[test]
1215 fn export_bundle_includes_coi_service_worker() {
1216 let temp = tempdir().expect("tempdir");
1217 let out = temp.path().join("pages");
1218 let issues = vec![make_issue("A", "open")];
1219
1220 export_pages_bundle(
1221 &issues,
1222 &out,
1223 &ExportPagesOptions {
1224 title: None,
1225 subtitle: None,
1226 include_closed: false,
1227 include_history: false,
1228 },
1229 )
1230 .expect("export pages bundle");
1231
1232 assert!(
1234 out.join("coi-serviceworker.js").is_file(),
1235 "exported bundle must include coi-serviceworker.js"
1236 );
1237
1238 let index = fs::read_to_string(out.join("index.html")).expect("read index.html");
1240 assert!(
1241 index.contains("coi-serviceworker.js"),
1242 "index.html must reference the COI service worker"
1243 );
1244 }
1245
1246 #[test]
1247 fn exported_index_html_has_csp_meta_tag() {
1248 let temp = tempdir().expect("tempdir");
1249 let out = temp.path().join("pages");
1250 let issues = vec![make_issue("A", "open")];
1251
1252 export_pages_bundle(
1253 &issues,
1254 &out,
1255 &ExportPagesOptions {
1256 title: None,
1257 subtitle: None,
1258 include_closed: false,
1259 include_history: false,
1260 },
1261 )
1262 .expect("export pages bundle");
1263
1264 let index = fs::read_to_string(out.join("index.html")).expect("read index.html");
1265 assert!(
1266 index.contains("Content-Security-Policy"),
1267 "exported index.html must include CSP meta tag"
1268 );
1269 assert!(
1271 index.contains("default-src") && index.contains("connect-src"),
1272 "CSP must include default-src and connect-src directives"
1273 );
1274 }
1275
1276 #[test]
1277 fn cache_control_header_disables_caching() {
1278 let header = format!(
1280 "HTTP/1.1 200 OK\r\n\
1281 Content-Type: text/html; charset=utf-8\r\n\
1282 Content-Length: 5\r\n\
1283 Cache-Control: no-store, no-cache, must-revalidate, max-age=0\r\n\
1284 Pragma: no-cache\r\n\
1285 Expires: 0\r\n\
1286 Connection: close\r\n\r\n"
1287 );
1288 assert!(header.contains("no-store"));
1290 assert!(header.contains("no-cache"));
1291 assert!(header.contains("must-revalidate"));
1292 assert!(header.contains("max-age=0"));
1293 assert!(header.contains("Pragma: no-cache"));
1294 assert!(header.contains("Expires: 0"));
1295 }
1296
1297 #[test]
1298 fn mime_type_for_wasm_returns_correct_type() {
1299 assert_eq!(
1300 mime_type_for_path(Path::new("vendor/sql-wasm.wasm")),
1301 "application/wasm"
1302 );
1303 assert_eq!(
1304 mime_type_for_path(Path::new("vendor/inter.woff2")),
1305 "font/woff2"
1306 );
1307 assert_eq!(
1308 mime_type_for_path(Path::new("viewer.js")),
1309 "application/javascript; charset=utf-8"
1310 );
1311 assert_eq!(
1312 mime_type_for_path(Path::new("styles.css")),
1313 "text/css; charset=utf-8"
1314 );
1315 assert_eq!(
1316 mime_type_for_path(Path::new("beads.sqlite3")),
1317 "application/x-sqlite3"
1318 );
1319 }
1320
1321 #[test]
1322 fn export_bundle_includes_deploy_readme() {
1323 let temp = tempdir().expect("tempdir");
1324 let out = temp.path().join("pages");
1325 let issues = vec![make_issue("A", "open")];
1326
1327 let summary = export_pages_bundle(
1328 &issues,
1329 &out,
1330 &ExportPagesOptions {
1331 title: Some("Sprint 42".to_string()),
1332 subtitle: None,
1333 include_closed: false,
1334 include_history: false,
1335 },
1336 )
1337 .expect("export pages bundle");
1338
1339 assert!(out.join("README.md").is_file());
1340 assert!(summary.files.contains(&"README.md".to_string()));
1341
1342 let readme = fs::read_to_string(out.join("README.md")).expect("read README");
1343 assert!(readme.contains("# Sprint 42"));
1344 assert!(readme.contains("bvr"));
1345 assert!(readme.contains("GitHub Pages"));
1346 assert!(readme.contains("Cloudflare Pages"));
1347 assert!(readme.contains("Issues"));
1348 }
1349
1350 #[test]
1351 fn export_bundle_includes_static_host_headers() {
1352 let temp = tempdir().expect("tempdir");
1353 let out = temp.path().join("pages");
1354 let issues = vec![make_issue("A", "open")];
1355
1356 let summary = export_pages_bundle(
1357 &issues,
1358 &out,
1359 &ExportPagesOptions {
1360 title: None,
1361 subtitle: None,
1362 include_closed: false,
1363 include_history: false,
1364 },
1365 )
1366 .expect("export pages bundle");
1367
1368 assert!(out.join("_headers").is_file());
1369 assert!(summary.files.contains(&"_headers".to_string()));
1370
1371 let headers = fs::read_to_string(out.join("_headers")).expect("read _headers");
1372 assert!(headers.contains("Cross-Origin-Embedder-Policy"));
1373 assert!(headers.contains("Cross-Origin-Opener-Policy"));
1374 assert!(headers.contains("application/wasm"));
1375 assert!(headers.contains("application/x-sqlite3"));
1376 }
1377
1378 #[test]
1379 fn generate_deploy_readme_includes_key_sections() {
1380 let meta = PagesMeta {
1381 title: "Test Project".to_string(),
1382 subtitle: Some("Subheading".to_string()),
1383 generated_at: "2026-03-09T12:00:00Z".to_string(),
1384 issue_count: 42,
1385 include_closed: true,
1386 include_history: true,
1387 generator: "bvr".to_string(),
1388 version: env!("CARGO_PKG_VERSION").to_string(),
1389 };
1390 let readme = generate_deploy_readme("Test Project", &meta);
1391 assert!(readme.contains("# Test Project"));
1392 assert!(readme.contains("## Quick start"));
1393 assert!(readme.contains("## Contents"));
1394 assert!(readme.contains("## Generation info"));
1395 assert!(readme.contains("Issues**: 42"));
1396 assert!(readme.contains("v0.1.0"));
1397 }
1398
1399 #[test]
1400 fn preview_status_reports_urls_and_reload_mode() {
1401 let temp = tempdir().expect("tempdir");
1402 fs::write(temp.path().join("index.html"), "<html></html>").expect("write index");
1403
1404 let status = preview_status(temp.path(), true, 9123).expect("preview status");
1405 assert_eq!(status.url, "http://127.0.0.1:9123");
1406 assert_eq!(status.reload_mode, "poll");
1407 assert_eq!(
1408 status.status_url,
1409 "http://127.0.0.1:9123/__preview__/status"
1410 );
1411 assert_eq!(status.reload_endpoint, Some("/.bvr/livereload"));
1412 }
1413
1414 #[test]
1417 fn export_empty_issue_list_produces_valid_bundle() {
1418 let temp = tempdir().expect("tempdir");
1419 let out = temp.path().join("pages");
1420
1421 let summary = export_pages_bundle(
1422 &[],
1423 &out,
1424 &ExportPagesOptions {
1425 title: Some("Empty Project".to_string()),
1426 subtitle: None,
1427 include_closed: true,
1428 include_history: true,
1429 },
1430 )
1431 .expect("export pages bundle");
1432
1433 assert_eq!(summary.issue_count, 0);
1434 assert!(out.join("index.html").is_file());
1435 assert!(out.join("data/meta.json").is_file());
1436 assert!(out.join("data/issues.json").is_file());
1437 assert!(out.join("data/triage.json").is_file());
1438 assert!(out.join("data/insights.json").is_file());
1439 assert!(out.join("beads.sqlite3").is_file());
1440 assert!(out.join("README.md").is_file());
1441 assert!(out.join("_headers").is_file());
1442
1443 let issues_json: Vec<serde_json::Value> =
1444 serde_json::from_str(&fs::read_to_string(out.join("data/issues.json")).expect("read"))
1445 .expect("parse");
1446 assert!(issues_json.is_empty());
1447 }
1448
1449 #[test]
1450 fn export_empty_issues_history_still_written_when_enabled() {
1451 let temp = tempdir().expect("tempdir");
1452 let out = temp.path().join("pages");
1453
1454 export_pages_bundle(
1455 &[],
1456 &out,
1457 &ExportPagesOptions {
1458 title: None,
1459 subtitle: None,
1460 include_closed: true,
1461 include_history: true,
1462 },
1463 )
1464 .expect("export pages bundle");
1465
1466 assert!(
1467 out.join("data/history.json").is_file(),
1468 "history.json must be emitted even for empty issue list"
1469 );
1470 }
1471
1472 #[test]
1475 fn export_empty_title_falls_back_to_default() {
1476 let temp = tempdir().expect("tempdir");
1477 let out = temp.path().join("pages");
1478
1479 export_pages_bundle(
1480 &[make_issue("A", "open")],
1481 &out,
1482 &ExportPagesOptions {
1483 title: Some("".to_string()),
1484 subtitle: None,
1485 include_closed: false,
1486 include_history: false,
1487 },
1488 )
1489 .expect("export");
1490
1491 let meta: serde_json::Value =
1492 serde_json::from_str(&fs::read_to_string(out.join("data/meta.json")).expect("read"))
1493 .expect("parse");
1494 assert_eq!(meta["title"], "Project Issues");
1495 }
1496
1497 #[test]
1498 fn export_whitespace_title_falls_back_to_default() {
1499 let temp = tempdir().expect("tempdir");
1500 let out = temp.path().join("pages");
1501
1502 export_pages_bundle(
1503 &[make_issue("A", "open")],
1504 &out,
1505 &ExportPagesOptions {
1506 title: Some(" \t ".to_string()),
1507 subtitle: None,
1508 include_closed: false,
1509 include_history: false,
1510 },
1511 )
1512 .expect("export");
1513
1514 let meta: serde_json::Value =
1515 serde_json::from_str(&fs::read_to_string(out.join("data/meta.json")).expect("read"))
1516 .expect("parse");
1517 assert_eq!(meta["title"], "Project Issues");
1518 }
1519
1520 #[test]
1521 fn export_none_title_falls_back_to_default() {
1522 let temp = tempdir().expect("tempdir");
1523 let out = temp.path().join("pages");
1524
1525 export_pages_bundle(
1526 &[make_issue("A", "open")],
1527 &out,
1528 &ExportPagesOptions {
1529 title: None,
1530 subtitle: None,
1531 include_closed: false,
1532 include_history: false,
1533 },
1534 )
1535 .expect("export");
1536
1537 let meta: serde_json::Value =
1538 serde_json::from_str(&fs::read_to_string(out.join("data/meta.json")).expect("read"))
1539 .expect("parse");
1540 assert_eq!(meta["title"], "Project Issues");
1541 }
1542
1543 #[test]
1544 fn export_unicode_title_preserved_in_meta() {
1545 let temp = tempdir().expect("tempdir");
1546 let out = temp.path().join("pages");
1547 let title = "Sprint \u{1f680} Rocket";
1548
1549 export_pages_bundle(
1550 &[make_issue("A", "open")],
1551 &out,
1552 &ExportPagesOptions {
1553 title: Some(title.to_string()),
1554 subtitle: None,
1555 include_closed: false,
1556 include_history: false,
1557 },
1558 )
1559 .expect("export");
1560
1561 let meta: serde_json::Value =
1562 serde_json::from_str(&fs::read_to_string(out.join("data/meta.json")).expect("read"))
1563 .expect("parse");
1564 assert_eq!(meta["title"], title);
1565 }
1566
1567 #[test]
1568 fn export_subtitle_is_preserved_in_meta_and_bundle_readme() {
1569 let temp = tempdir().expect("tempdir");
1570 let out = temp.path().join("pages");
1571
1572 export_pages_bundle(
1573 &[make_issue("A", "open")],
1574 &out,
1575 &ExportPagesOptions {
1576 title: Some("Dashboard".to_string()),
1577 subtitle: Some("Nightly triage snapshot".to_string()),
1578 include_closed: false,
1579 include_history: false,
1580 },
1581 )
1582 .expect("export");
1583
1584 let meta: serde_json::Value =
1585 serde_json::from_str(&fs::read_to_string(out.join("data/meta.json")).expect("read"))
1586 .expect("parse");
1587 assert_eq!(meta["subtitle"], "Nightly triage snapshot");
1588
1589 let readme = fs::read_to_string(out.join("README.md")).expect("read README");
1590 assert!(readme.contains("Nightly triage snapshot"));
1591 }
1592
1593 #[test]
1596 fn meta_json_has_all_required_fields() {
1597 let temp = tempdir().expect("tempdir");
1598 let out = temp.path().join("pages");
1599
1600 export_pages_bundle(
1601 &[make_issue("A", "open")],
1602 &out,
1603 &ExportPagesOptions {
1604 title: Some("Parity Test".to_string()),
1605 subtitle: None,
1606 include_closed: true,
1607 include_history: true,
1608 },
1609 )
1610 .expect("export");
1611
1612 let meta: serde_json::Value =
1613 serde_json::from_str(&fs::read_to_string(out.join("data/meta.json")).expect("read"))
1614 .expect("parse");
1615
1616 assert!(meta["title"].is_string());
1617 assert!(meta.get("subtitle").is_none());
1618 assert!(meta["generated_at"].is_string());
1619 assert!(meta["issue_count"].is_number());
1620 assert!(meta["include_closed"].is_boolean());
1621 assert!(meta["include_history"].is_boolean());
1622 assert!(meta["generator"].is_string());
1623 assert!(meta["version"].is_string());
1624 assert_eq!(meta["generator"], "bvr");
1625 }
1626
1627 #[test]
1630 fn triage_json_has_quick_ref_key() {
1631 let temp = tempdir().expect("tempdir");
1632 let out = temp.path().join("pages");
1633
1634 export_pages_bundle(
1635 &[make_issue("A", "open"), make_issue("B", "open")],
1636 &out,
1637 &ExportPagesOptions {
1638 title: None,
1639 subtitle: None,
1640 include_closed: false,
1641 include_history: false,
1642 },
1643 )
1644 .expect("export");
1645
1646 let triage: serde_json::Value =
1647 serde_json::from_str(&fs::read_to_string(out.join("data/triage.json")).expect("read"))
1648 .expect("parse");
1649
1650 assert!(
1651 triage.get("quick_ref").is_some(),
1652 "triage.json must contain quick_ref key"
1653 );
1654 }
1655
1656 #[test]
1657 fn insights_json_has_bottlenecks_key() {
1658 let temp = tempdir().expect("tempdir");
1659 let out = temp.path().join("pages");
1660
1661 export_pages_bundle(
1662 &[make_issue("A", "open")],
1663 &out,
1664 &ExportPagesOptions {
1665 title: None,
1666 subtitle: None,
1667 include_closed: false,
1668 include_history: false,
1669 },
1670 )
1671 .expect("export");
1672
1673 let insights: serde_json::Value = serde_json::from_str(
1674 &fs::read_to_string(out.join("data/insights.json")).expect("read"),
1675 )
1676 .expect("parse");
1677
1678 assert!(
1679 insights.get("Bottlenecks").is_some(),
1680 "insights.json must contain Bottlenecks key"
1681 );
1682 }
1683
1684 #[test]
1685 fn fallback_viewer_js_reads_exported_insights_key_shape() {
1686 assert!(
1687 JS_BUNDLE.contains("insights.Bottlenecks"),
1688 "fallback viewer must read the serialized Insights key shape"
1689 );
1690 assert!(
1691 !JS_BUNDLE.contains("insights.bottlenecks"),
1692 "fallback viewer must not rely on a lowercase insights key that export never emits"
1693 );
1694 }
1695
1696 #[test]
1699 fn export_summary_json_is_self_consistent() {
1700 let temp = tempdir().expect("tempdir");
1701 let out = temp.path().join("pages");
1702 let issues = vec![
1703 make_issue("A", "open"),
1704 make_issue("B", "closed"),
1705 make_issue("C", "open"),
1706 ];
1707
1708 let summary = export_pages_bundle(
1709 &issues,
1710 &out,
1711 &ExportPagesOptions {
1712 title: Some("Self Check".to_string()),
1713 subtitle: None,
1714 include_closed: true,
1715 include_history: false,
1716 },
1717 )
1718 .expect("export");
1719
1720 assert_eq!(summary.issue_count, 3);
1722 assert!(!summary.include_history);
1723 assert!(summary.include_closed);
1724 assert!(!summary.files.is_empty());
1725
1726 let disk_summary: serde_json::Value = serde_json::from_str(
1728 &fs::read_to_string(out.join("data/export_summary.json")).expect("read"),
1729 )
1730 .expect("parse");
1731 assert_eq!(disk_summary["issue_count"], 3);
1732 assert_eq!(disk_summary["include_closed"], true);
1733 assert_eq!(disk_summary["include_history"], false);
1734 }
1735
1736 #[test]
1737 fn export_summary_file_list_includes_core_artifacts() {
1738 let temp = tempdir().expect("tempdir");
1739 let out = temp.path().join("pages");
1740
1741 let summary = export_pages_bundle(
1742 &[make_issue("A", "open")],
1743 &out,
1744 &ExportPagesOptions {
1745 title: None,
1746 subtitle: None,
1747 include_closed: false,
1748 include_history: false,
1749 },
1750 )
1751 .expect("export");
1752
1753 let required = [
1754 "data/issues.json",
1755 "data/meta.json",
1756 "data/triage.json",
1757 "data/insights.json",
1758 "data/export_summary.json",
1759 "beads.sqlite3",
1760 "beads.sqlite3.config.json",
1761 "assets/style.css",
1762 "assets/viewer.js",
1763 "README.md",
1764 "_headers",
1765 ];
1766
1767 for artifact in &required {
1768 assert!(
1769 summary.files.contains(&artifact.to_string()),
1770 "summary.files must contain {artifact}"
1771 );
1772 }
1773 }
1774
1775 #[test]
1778 fn export_all_closed_with_exclude_yields_zero_issues() {
1779 let temp = tempdir().expect("tempdir");
1780 let out = temp.path().join("pages");
1781 let issues = vec![
1782 make_issue("A", "closed"),
1783 make_issue("B", "closed"),
1784 make_issue("C", "tombstone"),
1785 ];
1786
1787 let summary = export_pages_bundle(
1788 &issues,
1789 &out,
1790 &ExportPagesOptions {
1791 title: None,
1792 subtitle: None,
1793 include_closed: false,
1794 include_history: false,
1795 },
1796 )
1797 .expect("export");
1798
1799 assert_eq!(summary.issue_count, 0);
1800
1801 let issues_json: Vec<serde_json::Value> =
1802 serde_json::from_str(&fs::read_to_string(out.join("data/issues.json")).expect("read"))
1803 .expect("parse");
1804 assert!(issues_json.is_empty());
1805 }
1806
1807 #[test]
1808 fn export_include_closed_true_keeps_all_statuses() {
1809 let temp = tempdir().expect("tempdir");
1810 let out = temp.path().join("pages");
1811 let issues = vec![
1812 make_issue("A", "open"),
1813 make_issue("B", "closed"),
1814 make_issue("C", "in_progress"),
1815 ];
1816
1817 let summary = export_pages_bundle(
1818 &issues,
1819 &out,
1820 &ExportPagesOptions {
1821 title: None,
1822 subtitle: None,
1823 include_closed: true,
1824 include_history: false,
1825 },
1826 )
1827 .expect("export");
1828
1829 assert_eq!(summary.issue_count, 3);
1830 }
1831
1832 #[test]
1835 fn normalize_root_path_maps_to_index() {
1836 let path = normalize_request_path("/").expect("normalize /");
1837 assert_eq!(path, PathBuf::from("index.html"));
1838 }
1839
1840 #[test]
1841 fn normalize_trailing_slash_maps_to_index() {
1842 let path = normalize_request_path("/data/").expect("normalize /data/");
1843 assert_eq!(path, PathBuf::from("data/index.html"));
1844 }
1845
1846 #[test]
1847 fn normalize_normal_file_path() {
1848 let path = normalize_request_path("/data/meta.json").expect("normalize");
1849 assert_eq!(path, PathBuf::from("data/meta.json"));
1850 }
1851
1852 #[test]
1853 fn normalize_double_dot_rejected() {
1854 assert!(normalize_request_path("/../etc/passwd").is_err());
1855 assert!(normalize_request_path("/data/../../secret").is_err());
1856 }
1857
1858 #[test]
1861 fn mime_types_cover_all_bundle_extensions() {
1862 assert_eq!(
1863 mime_type_for_path(Path::new("index.html")),
1864 "text/html; charset=utf-8"
1865 );
1866 assert_eq!(
1867 mime_type_for_path(Path::new("data/meta.json")),
1868 "application/json; charset=utf-8"
1869 );
1870 assert_eq!(mime_type_for_path(Path::new("logo.svg")), "image/svg+xml");
1871 assert_eq!(mime_type_for_path(Path::new("photo.png")), "image/png");
1872 assert_eq!(mime_type_for_path(Path::new("pic.jpg")), "image/jpeg");
1873 assert_eq!(mime_type_for_path(Path::new("pic.jpeg")), "image/jpeg");
1874 assert_eq!(
1875 mime_type_for_path(Path::new("unknown.xyz")),
1876 "application/octet-stream"
1877 );
1878 }
1879
1880 #[test]
1883 fn inject_live_reload_without_body_tag() {
1884 let html = b"<html>no body tag here</html>".to_vec();
1885 let injected = String::from_utf8(inject_live_reload(html)).expect("utf8");
1886 assert!(
1887 injected.contains("window.location.reload"),
1888 "script must be appended even without </body>"
1889 );
1890 assert!(injected.contains("no body tag here"));
1891 }
1892
1893 #[test]
1894 fn inject_live_reload_empty_html() {
1895 let html = b"".to_vec();
1896 let injected = String::from_utf8(inject_live_reload(html)).expect("utf8");
1897 assert!(injected.contains("window.location.reload"));
1898 }
1899
1900 #[test]
1903 fn preview_reload_mode_disabled_has_no_endpoint() {
1904 let mode = PreviewReloadMode::Disabled;
1905 assert_eq!(mode.label(), "disabled");
1906 assert!(mode.reload_endpoint().is_none());
1907 assert!(mode.operator_summary().contains("disabled"));
1908 }
1909
1910 #[test]
1911 fn preview_reload_mode_poll_has_endpoint() {
1912 let mode = PreviewReloadMode::Poll;
1913 assert_eq!(mode.label(), "poll");
1914 assert!(mode.reload_endpoint().is_some());
1915 assert!(mode.operator_summary().contains("livereload"));
1916 }
1917
1918 #[test]
1919 fn preview_status_without_live_reload() {
1920 let temp = tempdir().expect("tempdir");
1921 fs::write(temp.path().join("index.html"), "<html></html>").expect("write index");
1922
1923 let status = preview_status(temp.path(), false, 9200).expect("preview status");
1924 assert_eq!(status.reload_mode, "disabled");
1925 assert!(status.reload_endpoint.is_none());
1926 assert!(!status.live_reload);
1927 }
1928
1929 #[test]
1932 fn export_twice_to_same_dir_succeeds() {
1933 let temp = tempdir().expect("tempdir");
1934 let out = temp.path().join("pages");
1935 let issues = vec![make_issue("A", "open")];
1936 let opts = ExportPagesOptions {
1937 title: Some("Idempotent".to_string()),
1938 subtitle: None,
1939 include_closed: false,
1940 include_history: false,
1941 };
1942
1943 let s1 = export_pages_bundle(&issues, &out, &opts).expect("first export");
1944 let s2 = export_pages_bundle(&issues, &out, &opts).expect("second export");
1945
1946 assert_eq!(s1.issue_count, s2.issue_count);
1947 assert_eq!(s1.files.len(), s2.files.len());
1948 }
1949
1950 #[test]
1951 fn export_reexport_without_history_removes_stale_history_file() {
1952 let temp = tempdir().expect("tempdir");
1953 let out = temp.path().join("pages");
1954 let issues = vec![make_issue("A", "open")];
1955
1956 export_pages_bundle(
1957 &issues,
1958 &out,
1959 &ExportPagesOptions {
1960 title: None,
1961 subtitle: None,
1962 include_closed: false,
1963 include_history: true,
1964 },
1965 )
1966 .expect("initial export with history");
1967 assert!(out.join("data/history.json").is_file());
1968
1969 export_pages_bundle(
1970 &issues,
1971 &out,
1972 &ExportPagesOptions {
1973 title: None,
1974 subtitle: None,
1975 include_closed: false,
1976 include_history: false,
1977 },
1978 )
1979 .expect("re-export without history");
1980
1981 assert!(
1982 !out.join("data/history.json").exists(),
1983 "stale history.json should be removed when history export is disabled"
1984 );
1985 }
1986
1987 #[test]
1990 fn export_sqlite_has_expected_tables() {
1991 let temp = tempdir().expect("tempdir");
1992 let out = temp.path().join("pages");
1993
1994 export_pages_bundle(
1995 &[make_issue("A", "open")],
1996 &out,
1997 &ExportPagesOptions {
1998 title: None,
1999 subtitle: None,
2000 include_closed: false,
2001 include_history: false,
2002 },
2003 )
2004 .expect("export");
2005
2006 let db = rusqlite::Connection::open(out.join("beads.sqlite3")).expect("open db");
2007 let tables: Vec<String> = db
2008 .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
2009 .expect("prepare")
2010 .query_map([], |row| row.get(0))
2011 .expect("query")
2012 .filter_map(|r| r.ok())
2013 .collect();
2014
2015 assert!(
2016 tables.contains(&"issues".to_string()),
2017 "must have issues table, got: {tables:?}"
2018 );
2019 }
2020
2021 #[test]
2022 fn export_sqlite_issue_count_matches() {
2023 let temp = tempdir().expect("tempdir");
2024 let out = temp.path().join("pages");
2025 let issues = vec![
2026 make_issue("A", "open"),
2027 make_issue("B", "open"),
2028 make_issue("C", "closed"),
2029 ];
2030
2031 export_pages_bundle(
2032 &issues,
2033 &out,
2034 &ExportPagesOptions {
2035 title: None,
2036 subtitle: None,
2037 include_closed: true,
2038 include_history: false,
2039 },
2040 )
2041 .expect("export");
2042
2043 let db = rusqlite::Connection::open(out.join("beads.sqlite3")).expect("open db");
2044 let count: i64 = db
2045 .query_row("SELECT COUNT(*) FROM issues", [], |row| row.get(0))
2046 .expect("count");
2047 assert_eq!(count, 3);
2048 }
2049
2050 #[test]
2053 fn static_host_headers_has_all_required_directives() {
2054 assert!(STATIC_HOST_HEADERS.contains("Cross-Origin-Embedder-Policy: require-corp"));
2055 assert!(STATIC_HOST_HEADERS.contains("Cross-Origin-Opener-Policy: same-origin"));
2056 assert!(STATIC_HOST_HEADERS.contains("X-Content-Type-Options: nosniff"));
2057 assert!(STATIC_HOST_HEADERS.contains("application/wasm"));
2058 assert!(STATIC_HOST_HEADERS.contains("application/json; charset=utf-8"));
2059 assert!(STATIC_HOST_HEADERS.contains("application/x-sqlite3"));
2060 assert!(STATIC_HOST_HEADERS.contains("/*.wasm"));
2062 assert!(STATIC_HOST_HEADERS.contains("/*.json"));
2063 assert!(STATIC_HOST_HEADERS.contains("/beads.sqlite3"));
2064 }
2065
2066 #[test]
2069 fn count_files_recursive_empty_dir() {
2070 let temp = tempdir().expect("tempdir");
2071 let count = count_files_recursive(temp.path()).expect("count");
2072 assert_eq!(count, 0);
2073 }
2074
2075 #[test]
2076 fn count_files_recursive_nested() {
2077 let temp = tempdir().expect("tempdir");
2078 fs::create_dir_all(temp.path().join("a/b")).expect("mkdir");
2079 fs::write(temp.path().join("a/b/c.txt"), "hi").expect("write");
2080 fs::write(temp.path().join("top.txt"), "hi").expect("write");
2081
2082 let count = count_files_recursive(temp.path()).expect("count");
2083 assert_eq!(count, 2);
2084 }
2085
2086 #[test]
2087 fn latest_modified_token_non_empty_dir() {
2088 let temp = tempdir().expect("tempdir");
2089 fs::write(temp.path().join("file.txt"), "hello").expect("write");
2090
2091 let token = latest_modified_token(temp.path()).expect("token");
2092 assert!(
2093 !token.is_empty(),
2094 "token must be non-empty for non-empty dir"
2095 );
2096 }
2097
2098 #[test]
2099 fn latest_modified_token_changes_for_same_size_rewrite() {
2100 let temp = tempdir().expect("tempdir");
2101 let path = temp.path().join("file.txt");
2102 fs::write(&path, "AAAA").expect("write first");
2103 let first = latest_modified_token(temp.path()).expect("first token");
2104
2105 fs::write(&path, "BBBB").expect("write second");
2106 let second = latest_modified_token(temp.path()).expect("second token");
2107
2108 assert_ne!(first, second, "same-size rewrites must change the token");
2109 }
2110
2111 #[cfg(unix)]
2112 #[test]
2113 fn count_files_recursive_skips_symlink_cycles() {
2114 use std::os::unix::fs::symlink;
2115
2116 let temp = tempdir().expect("tempdir");
2117 let real = temp.path().join("real");
2118 fs::create_dir_all(&real).expect("mkdir real");
2119 fs::write(real.join("file.txt"), "hello").expect("write real file");
2120 symlink(temp.path(), temp.path().join("loop")).expect("create loop symlink");
2121
2122 let count = count_files_recursive(temp.path()).expect("count");
2123 assert_eq!(count, 1);
2124 }
2125
2126 #[cfg(unix)]
2127 #[test]
2128 fn latest_modified_token_handles_symlink_cycles() {
2129 use std::os::unix::fs::symlink;
2130
2131 let temp = tempdir().expect("tempdir");
2132 fs::write(temp.path().join("index.html"), "<html></html>").expect("write index");
2133 symlink(temp.path(), temp.path().join("loop")).expect("create loop symlink");
2134
2135 let token = latest_modified_token(temp.path()).expect("token");
2136 assert!(!token.is_empty());
2137 }
2138
2139 #[cfg(unix)]
2140 #[test]
2141 fn resolve_preview_asset_path_rejects_symlink_escape() {
2142 use std::os::unix::fs::symlink;
2143
2144 let bundle = tempdir().expect("bundle tempdir");
2145 let outside = tempdir().expect("outside tempdir");
2146 let secret = outside.path().join("secret.txt");
2147 fs::write(&secret, "top secret").expect("write secret");
2148 symlink(&secret, bundle.path().join("secret.txt")).expect("symlink secret");
2149
2150 let resolved =
2151 resolve_preview_asset_path(bundle.path(), Path::new("secret.txt")).expect("resolve");
2152 assert!(resolved.is_none(), "symlink escape should be rejected");
2153 }
2154
2155 #[cfg(unix)]
2156 #[test]
2157 fn count_files_recursive_skips_symlinked_dir_outside_bundle() {
2158 use std::os::unix::fs::symlink;
2159
2160 let bundle = tempdir().expect("bundle tempdir");
2161 let outside = tempdir().expect("outside tempdir");
2162 fs::write(bundle.path().join("inside.txt"), "inside").expect("write inside");
2163 fs::write(outside.path().join("outside.txt"), "outside").expect("write outside");
2164 symlink(outside.path(), bundle.path().join("outside-link")).expect("symlink outside dir");
2165
2166 let count = count_files_recursive(bundle.path()).expect("count");
2167 assert_eq!(count, 1);
2168 }
2169
2170 #[cfg(unix)]
2171 #[test]
2172 fn latest_modified_token_ignores_symlinked_dir_outside_bundle() {
2173 use std::os::unix::fs::symlink;
2174
2175 let bundle = tempdir().expect("bundle tempdir");
2176 let outside = tempdir().expect("outside tempdir");
2177 fs::write(bundle.path().join("inside.txt"), "inside").expect("write inside");
2178 fs::write(outside.path().join("outside.txt"), "outside").expect("write outside");
2179 symlink(outside.path(), bundle.path().join("outside-link")).expect("symlink outside dir");
2180
2181 let base = latest_modified_token(bundle.path()).expect("base token");
2182 std::thread::sleep(std::time::Duration::from_millis(5));
2183 fs::write(outside.path().join("outside.txt"), "outside changed").expect("rewrite outside");
2184 let after = latest_modified_token(bundle.path()).expect("after token");
2185 assert_eq!(after, base, "outside changes must not affect bundle token");
2186 }
2187}