diff --git a/crates/tracey-core/src/code_units.rs b/crates/tracey-core/src/code_units.rs index e7882cc..ad5968f 100644 --- a/crates/tracey-core/src/code_units.rs +++ b/crates/tracey-core/src/code_units.rs @@ -3275,7 +3275,11 @@ function helper { } "#; let refs = extract_refs(Path::new("test.nix"), source); - assert_eq!(refs.len(), 2, "Should find refs in both # and /* */ comments"); + assert_eq!( + refs.len(), + 2, + "Should find refs in both # and /* */ comments" + ); assert_eq!(refs[0].req_id, "nix.line"); assert_eq!(refs[1].req_id, "nix.block"); } diff --git a/crates/tracey/src/bridge/export.rs b/crates/tracey/src/bridge/export.rs new file mode 100644 index 0000000..6b28bc6 --- /dev/null +++ b/crates/tracey/src/bridge/export.rs @@ -0,0 +1,1065 @@ +//! Static site export for tracey spec coverage data. +//! +//! `tracey export ` produces a fully self-contained directory of HTML +//! files that can be served by any static file host. No daemon or JavaScript +//! framework is required to view the exported pages. + +use std::path::{Path, PathBuf}; + +use eyre::{Result, WrapErr, eyre}; +use tracey_api::{ + ApiCodeUnit, ApiConfig, ApiFileData, ApiReverseData, ApiRule, ApiSpecData, ApiSpecForward, + OutlineEntry, +}; + +pub async fn run( + root: Option, + _config_path: PathBuf, + output: PathBuf, + include_sources: bool, +) -> Result<()> { + let project_root = match root { + Some(r) => r, + None => crate::find_project_root().wrap_err("finding project root")?, + }; + + let client = crate::daemon::new_client(project_root); + let config = client + .config() + .await + .map_err(|e| eyre!("config RPC failed: {:?}", e))?; + + std::fs::create_dir_all(&output) + .wrap_err_with(|| format!("creating output directory {}", output.display()))?; + write_assets(&output).wrap_err("writing static assets")?; + + let first = config.specs.first().and_then(|s| { + s.implementations + .first() + .map(|i| (s.name.clone(), i.clone())) + }); + write_root_index(&output, first.as_ref()).wrap_err("writing root index.html")?; + + for spec_info in &config.specs { + for impl_name in &spec_info.implementations { + let spec_name = &spec_info.name; + eprintln!("Exporting {spec_name} × {impl_name}…"); + + let forward = client + .forward(spec_name.clone(), impl_name.clone()) + .await + .map_err(|e| eyre!("forward RPC failed for {spec_name}/{impl_name}: {:?}", e))? + .ok_or_else(|| eyre!("no forward data for {spec_name}/{impl_name}"))?; + + let reverse = client + .reverse(spec_name.clone(), impl_name.clone()) + .await + .map_err(|e| eyre!("reverse RPC failed for {spec_name}/{impl_name}: {:?}", e))? + .ok_or_else(|| eyre!("no reverse data for {spec_name}/{impl_name}"))?; + + let spec_content = client + .spec_content(spec_name.clone(), impl_name.clone()) + .await + .map_err(|e| { + eyre!( + "spec_content RPC failed for {spec_name}/{impl_name}: {:?}", + e + ) + })? + .ok_or_else(|| eyre!("no spec content for {spec_name}/{impl_name}"))?; + + let pair_dir = output.join(spec_name).join(impl_name); + std::fs::create_dir_all(&pair_dir) + .wrap_err_with(|| format!("creating directory {}", pair_dir.display()))?; + + std::fs::write( + pair_dir.join("spec.html"), + render_spec_page( + spec_name, + impl_name, + &spec_content, + &config, + include_sources, + ) + .wrap_err_with(|| format!("rendering spec page for {spec_name}/{impl_name}"))?, + ) + .wrap_err_with(|| format!("writing {spec_name}/{impl_name}/spec.html"))?; + + std::fs::write( + pair_dir.join("coverage.html"), + render_coverage_page(spec_name, impl_name, &forward, &config, include_sources) + .wrap_err_with(|| { + format!("rendering coverage page for {spec_name}/{impl_name}") + })?, + ) + .wrap_err_with(|| format!("writing {spec_name}/{impl_name}/coverage.html"))?; + + if include_sources { + std::fs::write( + pair_dir.join("sources.html"), + render_sources_index(spec_name, impl_name, &reverse, &config).wrap_err_with( + || format!("rendering sources index for {spec_name}/{impl_name}"), + )?, + ) + .wrap_err_with(|| format!("writing {spec_name}/{impl_name}/sources.html"))?; + + let sources_dir = pair_dir.join("sources"); + for file_entry in &reverse.files { + let path = &file_entry.path; + let req = tracey_proto::FileRequest { + spec: spec_name.clone(), + impl_name: impl_name.clone(), + path: path.clone(), + }; + if let Some(file_data) = client + .file(req) + .await + .map_err(|e| eyre!("file RPC failed for {path}: {:?}", e))? + { + let file_html = render_file_page(spec_name, impl_name, &file_data, &config) + .wrap_err_with(|| format!("rendering file page for {path}"))?; + let out_path = sources_dir.join(format!("{path}.html")); + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent).wrap_err_with(|| { + format!("creating directory {}", parent.display()) + })?; + } + std::fs::write(&out_path, file_html) + .wrap_err_with(|| format!("writing {}", out_path.display()))?; + } + } + } + } + } + + eprintln!("\nDone! Static site written to: {}", output.display()); + eprintln!( + "Serve with: python3 -m http.server -d {}", + output.display() + ); + Ok(()) +} + +// ============================================================================ +// Assets +// ============================================================================ + +fn write_assets(output: &Path) -> Result<()> { + let assets_dir = output.join("assets"); + std::fs::create_dir_all(&assets_dir) + .wrap_err_with(|| format!("creating assets directory {}", assets_dir.display()))?; + + let full_css = format!("{}\n{}", crate::bridge::http::INDEX_CSS, STATIC_EXTRA_CSS); + std::fs::write(assets_dir.join("style.css"), full_css).wrap_err("writing assets/style.css")?; + std::fs::write(assets_dir.join("enhance.js"), ENHANCE_JS) + .wrap_err("writing assets/enhance.js")?; + Ok(()) +} + +fn write_root_index(output: &Path, first: Option<&(String, String)>) -> Result<()> { + let html = if let Some((spec, impl_name)) = first { + format!( + r#" + + + + + Tracey + +

Redirecting to spec

+"# + ) + } else { + "\n\nTracey\n

No specs configured.

\n\n".to_string() + }; + std::fs::write(output.join("index.html"), html).wrap_err("writing index.html")?; + Ok(()) +} + +// ============================================================================ +// Page shell — uses dashboard CSS classes directly +// ============================================================================ + +#[allow(clippy::too_many_arguments)] +fn page_shell( + title: &str, + spec_name: &str, + impl_name: &str, + active_tab: &str, // "spec" | "coverage" | "sources" + config: &ApiConfig, + include_sources: bool, + sidebar_html: &str, + content_html: &str, + head_extras: &str, +) -> String { + // Spec/impl selector tabs in the header-pickers area + let spec_links = config + .specs + .iter() + .flat_map(|s| { + s.implementations.iter().map(move |i| { + let active = if s.name == spec_name && i == impl_name { + " active" + } else { + "" + }; + format!( + r#"{} / {}"#, + s.name, + i, + html_escape(&s.name), + html_escape(i), + ) + }) + }) + .collect::>() + .join("\n"); + + // Nav tabs + let tab = |label: &str, icon: &str, href: &str, key: &str| { + let active = if active_tab == key { " active" } else { "" }; + format!( + r#"{label}"# + ) + }; + let sources_tab = if include_sources { + tab( + "Sources", + "code-2", + &format!("/{spec_name}/{impl_name}/sources.html"), + "sources", + ) + } else { + String::new() + }; + let nav_tabs = format!( + "{}\n{}\n{sources_tab}", + tab( + "Specification", + "file-text", + &format!("/{spec_name}/{impl_name}/spec.html"), + "spec" + ), + tab( + "Coverage", + "bar-chart-2", + &format!("/{spec_name}/{impl_name}/coverage.html"), + "coverage" + ), + ); + + // Sidebar (omit the