Skip to main content

bvr/
export_pages.rs

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    // Pre-flight validation: ensure output directory is accessible.
235    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    // Write the canonical viewer asset inventory (deterministic, sorted order).
276    let asset_paths = crate::viewer_assets::write_viewer_assets(output_dir)?;
277    files.extend(asset_paths);
278
279    // Also write the lightweight Rust-generated assets under assets/ for
280    // backward compatibility — the canonical index.html does not reference
281    // these, but existing integrations may rely on their presence.
282    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        // Convert to the richer HistoryBeadCompat format that matches
316        // --robot-history output shape (with commits/cycle_time/milestones).
317        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    // Deploy-facing README so the bundle is self-describing.
358    write_text(
359        output_dir.join("README.md"),
360        &generate_deploy_readme(&title, &meta),
361    )?;
362    files.push("README.md".to_string());
363
364    // Static-host header hints (Cloudflare Pages, Netlify, etc.).
365    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        // COI service worker must be present for cross-origin isolation on static hosts
1233        assert!(
1234            out.join("coi-serviceworker.js").is_file(),
1235            "exported bundle must include coi-serviceworker.js"
1236        );
1237
1238        // Index must reference service worker for registration
1239        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        // CSP must enforce self-contained (offline) deployment
1270        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        // Verify the no-cache header string matches the expected contract
1279        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        // All cache-disabling directives must be present
1289        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    // ── Empty issue list ──────────────────────────────────────────────
1415
1416    #[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    // ── Title edge cases ──────────────────────────────────────────────
1473
1474    #[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    // ── Meta JSON schema validation ───────────────────────────────────
1594
1595    #[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    // ── Triage / insights JSON shape ──────────────────────────────────
1628
1629    #[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    // ── Export summary validation ─────────────────────────────────────
1697
1698    #[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        // Summary matches what was exported
1721        assert_eq!(summary.issue_count, 3);
1722        assert!(!summary.include_history);
1723        assert!(summary.include_closed);
1724        assert!(!summary.files.is_empty());
1725
1726        // Round-trip: the on-disk summary matches
1727        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    // ── Filtering edge cases ──────────────────────────────────────────
1776
1777    #[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    // ── Normalize path edge cases ─────────────────────────────────────
1833
1834    #[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    // ── MIME type coverage ────────────────────────────────────────────
1859
1860    #[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    // ── Live reload injection edge cases ──────────────────────────────
1881
1882    #[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    // ── Preview reload mode ───────────────────────────────────────────
1901
1902    #[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    // ── Export idempotency ────────────────────────────────────────────
1930
1931    #[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    // ── SQLite DB table validation ────────────────────────────────────
1988
1989    #[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    // ── Static host headers contract ──────────────────────────────────
2051
2052    #[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        // Glob patterns for file type matching
2061        assert!(STATIC_HOST_HEADERS.contains("/*.wasm"));
2062        assert!(STATIC_HOST_HEADERS.contains("/*.json"));
2063        assert!(STATIC_HOST_HEADERS.contains("/beads.sqlite3"));
2064    }
2065
2066    // ── Count / modified helpers ───────────────────────────────────────
2067
2068    #[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}