diff --git a/artifacts/architecture.yaml b/artifacts/architecture.yaml index 7be91de..a6ac712 100644 --- a/artifacts/architecture.yaml +++ b/artifacts/architecture.yaml @@ -15,6 +15,7 @@ artifacts: classifier-kind: type aadl-file: arch/rivet_system.aadl:49 source-ref: arch/rivet_system.aadl:49-54 + diagram: "root: RivetSystem::Rivet.Impl" - id: ARCH-SYS-002 type: aadl-component diff --git a/artifacts/decisions.yaml b/artifacts/decisions.yaml index 45eea1a..3c1bf54 100644 --- a/artifacts/decisions.yaml +++ b/artifacts/decisions.yaml @@ -19,6 +19,15 @@ artifacts: alternatives: > Per-tool REST adapters (Polarion REST, DOORS DNG API). Rejected because each requires separate auth, pagination, and schema mapping. + diagram: | + graph LR + A[Rivet Core] -->|OSLC| B[Polarion] + A -->|OSLC| C[DOORS] + A -->|OSLC| D[codebeamer] + style A fill:#e8f4fd,stroke:#0550ae + style B fill:#f0f0f0,stroke:#666 + style C fill:#f0f0f0,stroke:#666 + style D fill:#f0f0f0,stroke:#666 - id: DD-002 type: design-decision diff --git a/docs/srs.md b/docs/srs.md index 3b12129..d2a1286 100644 --- a/docs/srs.md +++ b/docs/srs.md @@ -82,6 +82,33 @@ audit, deny, vet, coverage). [[REQ-011]] pins Rust edition 2024 with MSRV 1.85. +### 3.7 Traceability Flow + +The following diagram shows the traceability chain from stakeholder needs +through to verification evidence: + +```mermaid +graph TD + REQ[Requirements] -->|satisfies| DD[Design Decisions] + REQ -->|allocated-to| ARCH[Architecture] + DD -->|implemented-by| FEAT[Features] + FEAT -->|verified-by| TEST[Test Artifacts] + TEST -->|evidence| RES[Test Results] + style REQ fill:#e8f4fd,stroke:#0550ae + style ARCH fill:#f0e6ff,stroke:#6639ba + style TEST fill:#e6ffe6,stroke:#15713a +``` + +### 3.8 Key Requirement Details + +The following requirement is the cornerstone of the system: + +{{artifact:REQ-001}} + +And the design decision that shapes tool integration: + +{{artifact:DD-001}} + ## 4. Glossary See the glossary panel below (defined in document frontmatter). diff --git a/rivet-cli/build.rs b/rivet-cli/build.rs new file mode 100644 index 0000000..e127f85 --- /dev/null +++ b/rivet-cli/build.rs @@ -0,0 +1,60 @@ +use std::process::Command; + +fn main() { + // Emit git metadata as compile-time environment variables. + println!("cargo:rerun-if-changed=../.git/HEAD"); + println!("cargo:rerun-if-changed=../.git/index"); + + let git = |args: &[&str]| -> String { + Command::new("git") + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default() + }; + + let commit = git(&["rev-parse", "--short=8", "HEAD"]); + let branch = git(&["rev-parse", "--abbrev-ref", "HEAD"]); + let dirty = !git(&["status", "--porcelain"]).is_empty(); + + // Count uncommitted changes by category + let status_output = git(&["status", "--porcelain"]); + let mut staged = 0u32; + let mut modified = 0u32; + let mut untracked = 0u32; + for line in status_output.lines() { + if line.len() < 2 { + continue; + } + let index = line.as_bytes()[0]; + let worktree = line.as_bytes()[1]; + if line.starts_with("??") { + untracked += 1; + } else { + if index != b' ' && index != b'?' { + staged += 1; + } + if worktree != b' ' && worktree != b'?' { + modified += 1; + } + } + } + + let build_date = Command::new("date") + .arg("+%Y-%m-%d") + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + println!("cargo:rustc-env=RIVET_GIT_COMMIT={commit}"); + println!("cargo:rustc-env=RIVET_GIT_BRANCH={branch}"); + println!("cargo:rustc-env=RIVET_GIT_DIRTY={dirty}"); + println!("cargo:rustc-env=RIVET_GIT_STAGED={staged}"); + println!("cargo:rustc-env=RIVET_GIT_MODIFIED={modified}"); + println!("cargo:rustc-env=RIVET_GIT_UNTRACKED={untracked}"); + println!("cargo:rustc-env=RIVET_BUILD_DATE={build_date}"); +} diff --git a/rivet-cli/src/docs.rs b/rivet-cli/src/docs.rs index abcf71b..9bb20cc 100644 --- a/rivet-cli/src/docs.rs +++ b/rivet-cli/src/docs.rs @@ -39,6 +39,12 @@ const TOPICS: &[DocTopic] = &[ category: "Reference", content: JSON_DOC, }, + DocTopic { + slug: "documents", + title: "Documents — markdown with frontmatter, images, and diagrams", + category: "Reference", + content: DOCUMENTS_DOC, + }, DocTopic { slug: "schema/common", title: "Common base fields and link types", @@ -329,6 +335,131 @@ rivet validate --format json | jq -e '.errors == 0' > /dev/null && echo "PASS" | ``` "#; +const DOCUMENTS_DOC: &str = r#"# Documents + +Rivet treats markdown files as first-class project documents. Documents are +loaded from directories listed under `docs:` in `rivet.yaml`, parsed for +YAML frontmatter, and scanned for artifact references. + +## Directory Layout + +```yaml +# rivet.yaml +docs: + - docs # loads docs/*.md recursively + - arch # loads arch/*.md recursively +``` + +Each `.md` file becomes a document in the dashboard's Documents view. + +## Frontmatter + +Every document should start with a YAML frontmatter block: + +```yaml +--- +id: DOC-SRS +title: Software Requirements Specification +type: specification +status: approved +tags: [requirements, safety] +--- +``` + +| Field | Required | Description | +|--------|----------|------------------------------------------| +| id | yes | Unique document identifier | +| title | yes | Display title | +| type | no | Document type (specification, plan, etc.) | +| status | no | Lifecycle status (draft, approved, etc.) | +| tags | no | Categorization tags | + +## Artifact References + +Use `[[ID]]` syntax to reference artifacts anywhere in the document body: + +```markdown +The latency requirement [[REQ-001]] is satisfied by design decision [[DD-005]]. +``` + +These are rendered as clickable links in the dashboard and tracked in the +document-artifact linkage view. Broken references (IDs not found in the +artifact store) are visually flagged. + +## Images + +Embed images using standard markdown syntax: + +```markdown +![Architecture diagram](images/arch-overview.png) +![Sequence flow](images/flow.svg) +``` + +Images are resolved relative to the document's `docs:` directory. +Place images in a subdirectory (e.g. `docs/images/`) and reference them +with a relative path. + +Supported formats: PNG, JPEG, GIF, SVG, WebP. + +In the dashboard, image paths are served via `/docs-asset/` — e.g. +`images/arch.png` in a doc becomes `/docs-asset/images/arch.png`. + +## Mermaid Diagrams + +Embed diagrams using fenced code blocks with the `mermaid` language tag: + +````markdown +```mermaid +graph TD + REQ-001 -->|satisfies| FEAT-001 + REQ-001 -->|derives-from| SYS-REQ-001 + DD-005 -->|implements| REQ-001 +``` +```` + +Mermaid diagrams are rendered client-side in the dashboard. Supported +diagram types include: + +- **flowchart / graph** — dependency and flow diagrams +- **sequence** — interaction sequences +- **state** — state machines +- **class** — structure diagrams +- **gantt** — timeline views +- **C4** — architecture (C4 model) + +### Tips + +- Use artifact IDs as node names to match traceability +- Keep diagrams focused (10-20 nodes max) for readability +- The `mermaid` block is passed through as-is in CLI text output + +## AADL Diagrams + +If you have spar (AADL parser) integration, use `aadl` code blocks: + +````markdown +```aadl +root: flight_controller +``` +```` + +These are rendered as interactive architecture diagrams via the WASM runtime. + +## Sections and TOC + +Headings (`##`, `###`, etc.) are parsed into sections. Documents with more +than two sections automatically get a table of contents in the dashboard. +Section-level artifact reference counts are shown in the TOC. + +## Validation + +Documents participate in validation: + +- **Broken references**: `[[ID]]` pointing to nonexistent artifacts are warnings +- **Coverage**: The doc-linkage view shows which artifacts are referenced in docs +- **Orphan detection**: Artifacts never referenced in any document are flagged +"#; + // ── Public API ────────────────────────────────────────────────────────── /// List all available documentation topics. diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 1c6a06d..e258597 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -18,8 +18,43 @@ mod docs; mod schema_cmd; mod serve; +fn build_version() -> &'static str { + use std::sync::LazyLock; + static VERSION: LazyLock = LazyLock::new(|| { + let version = env!("CARGO_PKG_VERSION"); + let commit = env!("RIVET_GIT_COMMIT"); + let branch = env!("RIVET_GIT_BRANCH"); + let dirty: bool = env!("RIVET_GIT_DIRTY").parse().unwrap_or(false); + let staged: u32 = env!("RIVET_GIT_STAGED").parse().unwrap_or(0); + let modified: u32 = env!("RIVET_GIT_MODIFIED").parse().unwrap_or(0); + let untracked: u32 = env!("RIVET_GIT_UNTRACKED").parse().unwrap_or(0); + let date = env!("RIVET_BUILD_DATE"); + + let mut s = format!("{version} ({commit} {branch} {date})"); + if dirty { + let mut parts = Vec::new(); + if staged > 0 { + parts.push(format!("{staged} staged")); + } + if modified > 0 { + parts.push(format!("{modified} modified")); + } + if untracked > 0 { + parts.push(format!("{untracked} untracked")); + } + if parts.is_empty() { + s.push_str(" [dirty]"); + } else { + s.push_str(&format!(" [{}]", parts.join(", "))); + } + } + s + }); + &VERSION +} + #[derive(Parser)] -#[command(name = "rivet", about = "SDLC artifact traceability and validation")] +#[command(name = "rivet", about = "SDLC artifact traceability and validation", version = build_version())] struct Cli { /// Path to the project directory (containing rivet.yaml) #[arg(short, long, default_value = ".")] @@ -323,6 +358,7 @@ fn run(cli: Cli) -> Result { project_name, project_path, schemas_dir, + doc_dirs, ) = load_project_full(&cli)?; let rt = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?; rt.block_on(serve::run( @@ -334,6 +370,7 @@ fn run(cli: Cli) -> Result { project_name, project_path, schemas_dir, + doc_dirs, port, ))?; Ok(true) @@ -1621,6 +1658,7 @@ fn load_project_full( String, PathBuf, PathBuf, + Vec, )> { let config_path = cli.project.join("rivet.yaml"); let config = rivet_core::load_project_config(&config_path) @@ -1643,8 +1681,12 @@ fn load_project_full( // Load documents let mut doc_store = DocumentStore::new(); + let mut doc_dirs = Vec::new(); for docs_path in &config.docs { let dir = cli.project.join(docs_path); + if dir.is_dir() { + doc_dirs.push(dir.clone()); + } let docs = document::load_documents(&dir) .with_context(|| format!("loading docs from '{docs_path}'"))?; for doc in docs { @@ -1675,6 +1717,7 @@ fn load_project_full( project_name, project_path, schemas_dir, + doc_dirs, )) } diff --git a/rivet-cli/src/serve.rs b/rivet-cli/src/serve.rs index cee599f..dc36f50 100644 --- a/rivet-cli/src/serve.rs +++ b/rivet-cli/src/serve.rs @@ -169,6 +169,8 @@ struct AppState { project_path_buf: PathBuf, /// Path to the schemas directory (for reload). schemas_dir: PathBuf, + /// Resolved docs directories (for serving images/assets). + doc_dirs: Vec, } /// Convenience alias so handler signatures stay compact. @@ -199,8 +201,12 @@ fn reload_state( let graph = LinkGraph::build(&store, &schema); let mut doc_store = DocumentStore::new(); + let mut doc_dirs = Vec::new(); for docs_path in &config.docs { let dir = project_path.join(docs_path); + if dir.is_dir() { + doc_dirs.push(dir.clone()); + } let docs = rivet_core::document::load_documents(&dir) .with_context(|| format!("loading docs from '{docs_path}'"))?; for doc in docs { @@ -247,6 +253,7 @@ fn reload_state( context, project_path_buf: project_path.to_path_buf(), schemas_dir: schemas_dir.to_path_buf(), + doc_dirs, }) } @@ -261,6 +268,7 @@ pub async fn run( project_name: String, project_path: PathBuf, schemas_dir: PathBuf, + doc_dirs: Vec, port: u16, ) -> Result<()> { let git = capture_git_info(&project_path); @@ -290,6 +298,7 @@ pub async fn run( context, project_path_buf: project_path, schemas_dir, + doc_dirs, })); let app = Router::new() @@ -326,6 +335,7 @@ pub async fn run( .route("/help/schema/{name}", get(help_schema_show)) .route("/help/links", get(help_links_view)) .route("/help/rules", get(help_rules_view)) + .route("/docs-asset/{*path}", get(docs_asset)) .route("/reload", post(reload_handler)) .with_state(state) .layer(axum::middleware::from_fn(redirect_non_htmx)); @@ -356,6 +366,7 @@ async fn redirect_non_htmx( && !path.starts_with("/api/") && !path.starts_with("/wasm/") && !path.starts_with("/source-raw/") + && !path.starts_with("/docs-asset/") { let goto = urlencoding::encode(&path); return axum::response::Redirect::to(&format!("/?goto={goto}")).into_response(); @@ -496,7 +507,10 @@ async fn wasm_asset(Path(path): Path) -> impl IntoResponse { if let Ok(bytes) = std::fs::read(candidate) { return ( axum::http::StatusCode::OK, - [(axum::http::header::CONTENT_TYPE, content_type)], + [ + (axum::http::header::CONTENT_TYPE, content_type), + (axum::http::header::CACHE_CONTROL, "no-cache"), + ], bytes, ) .into_response(); @@ -535,11 +549,12 @@ async fn reload_handler( // Redirect back to wherever the user was (HTMX sends HX-Current-URL). // Extract the path portion from the full URL (e.g. "http://localhost:3001/documents/DOC-001" → "/documents/DOC-001"). + // Navigate back to wherever the user was (HTMX sends HX-Current-URL). + // HX-Location does a client-side HTMX navigation (fetch + swap + push-url). let redirect_url = headers .get("HX-Current-URL") .and_then(|v| v.to_str().ok()) .and_then(|full_url| { - // Find the path after the authority (scheme://host[:port]) full_url .find("://") .and_then(|i| full_url[i + 3..].find('/')) @@ -550,9 +565,14 @@ async fn reload_handler( }) .unwrap_or_else(|| "/".to_owned()); + let location_json = format!( + "{{\"path\":\"{}\",\"target\":\"#content\"}}", + redirect_url.replace('"', "\\\"") + ); + ( axum::http::StatusCode::OK, - [("HX-Redirect", redirect_url)], + [("HX-Location", location_json)], "reloaded".to_owned(), ) } @@ -560,13 +580,63 @@ async fn reload_handler( eprintln!("reload error: {e:#}"); ( axum::http::StatusCode::INTERNAL_SERVER_ERROR, - [("HX-Redirect", "/".to_owned())], + [("HX-Location", "{\"path\":\"/\",\"target\":\"#content\"}".to_owned())], format!("reload failed: {e}"), ) } } } +/// GET /docs-asset/{*path} — serve static files (images, SVG, etc.) from docs directories. +async fn docs_asset( + State(state): State, + Path(path): Path, +) -> impl IntoResponse { + let state = state.read().await; + + // Sanitize: reject path traversal + if path.contains("..") { + return ( + axum::http::StatusCode::BAD_REQUEST, + [("Content-Type", "text/plain")], + Vec::new(), + ); + } + + // Search through all doc directories for the requested file + for dir in &state.doc_dirs { + let file_path = dir.join(&path); + if file_path.is_file() { + if let Ok(bytes) = std::fs::read(&file_path) { + let content_type = match file_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "svg" => "image/svg+xml", + "webp" => "image/webp", + "pdf" => "application/pdf", + _ => "application/octet-stream", + }; + return ( + axum::http::StatusCode::OK, + [("Content-Type", content_type)], + bytes, + ); + } + } + } + + ( + axum::http::StatusCode::NOT_FOUND, + [("Content-Type", "text/plain")], + b"not found".to_vec(), + ) +} + // ── Color palette ──────────────────────────────────────────────────────── fn type_color_map() -> HashMap { @@ -724,6 +794,13 @@ td{border-bottom:1px solid var(--border)} tbody tr{transition:background var(--transition)} tbody tr:nth-child(even){background:rgba(0,0,0,.015)} tbody tr:hover{background:rgba(58,134,255,.04)} +.tbl-filter-wrap{margin-bottom:.5rem} +.tbl-filter{width:100%;max-width:20rem;padding:.4rem .65rem;font-size:.85rem;font-family:var(--mono); + border:1px solid var(--border);border-radius:5px;background:var(--surface);color:var(--text); + outline:none;transition:border-color var(--transition)} +.tbl-filter:focus{border-color:var(--accent)} +.tbl-sort-arrow{font-size:.7rem;opacity:.6;margin-left:.25rem} +th:hover .tbl-sort-arrow{opacity:1} td a{font-family:var(--mono);font-size:.85rem;font-weight:500} /* ── Badges ───────────────────────────────────────────────────── */ @@ -946,6 +1023,8 @@ details.diff-row>.diff-detail{padding:.75rem 1.25rem;background:rgba(0,0,0,.01); .doc-body p{margin:.5rem 0} .doc-body ul{margin:.5rem 0 .5rem 1.5rem} .doc-body li{margin:.2rem 0} +.doc-body img{border-radius:6px;margin:.75rem 0;box-shadow:0 2px 8px rgba(0,0,0,.1)} +.doc-body pre.mermaid{background:transparent;border:1px solid var(--border);border-radius:6px;padding:1rem;text-align:center} .artifact-ref{display:inline-flex;align-items:center;padding:.15rem .5rem;border-radius:5px; font-size:.8rem;font-weight:600;font-family:var(--mono);background:#edf2ff; color:#3a63c7;cursor:pointer;text-decoration:none; @@ -1090,6 +1169,27 @@ details.trace-details[open]>summary .trace-chevron{transform:rotate(90deg)} .trace-status-approved{background:rgba(21,113,58,.1);color:#15713a} .trace-status-draft{background:rgba(184,134,11,.1);color:#b8860b} +/* ── Artifact embedding in docs ────────────────────────────────── */ +.artifact-embed{margin:.75rem 0;padding:.75rem 1rem;background:var(--card-bg);border:1px solid var(--border); + border-radius:var(--radius);border-left:3px solid var(--accent)} +.artifact-embed-header{display:flex;align-items:center;gap:.5rem;margin-bottom:.35rem} +.artifact-embed-header .artifact-ref{font-family:var(--mono);font-size:.85rem;font-weight:600} +.artifact-embed-title{font-weight:600;font-size:.92rem;color:var(--text)} +.artifact-embed-desc{font-size:.82rem;color:var(--text-secondary);margin-top:.25rem;line-height:1.5} + +/* ── Diagram in artifact detail ────────────────────────────────── */ +.artifact-diagram{margin:1rem 0} +.artifact-diagram .mermaid{background:var(--card-bg);padding:1rem;border-radius:var(--radius); + border:1px solid var(--border)} + +/* ── AADL SVG style overrides (match etch) ────────────────────── */ +.aadl-viewport svg text{font-family:system-ui,-apple-system,BlinkMacSystemFont,sans-serif !important; + font-size:12px !important} +.aadl-viewport svg rect,.aadl-viewport svg polygon{rx:6;ry:6} +.aadl-viewport svg .node rect{stroke-width:1.5px;filter:drop-shadow(0 1px 3px rgba(0,0,0,.1))} +.aadl-viewport svg .edge path,.aadl-viewport svg .edge line{stroke:#888 !important;stroke-width:1.2px} +.aadl-viewport svg .edge polygon{fill:#888 !important;stroke:#888 !important} + /* ── Scrollbar (subtle) ───────────────────────────────────────── */ ::-webkit-scrollbar{width:6px;height:6px} ::-webkit-scrollbar-track{background:transparent} @@ -1151,14 +1251,34 @@ details.trace-details[open]>summary .trace-chevron{transform:rotate(90deg)} width:1.7rem;height:1.7rem;cursor:pointer;font-size:.85rem;line-height:1;display:flex; align-items:center;justify-content:center;color:var(--text-secondary);transition:all .15s} .aadl-controls button:hover{background:var(--primary);color:#fff;border-color:var(--primary)} -.aadl-viewport{overflow:hidden;padding:.5rem;cursor:grab;min-height:200px;position:relative} +.aadl-viewport{overflow:hidden;cursor:grab;min-height:300px;position:relative;background:var(--body-bg)} .aadl-viewport.grabbing{cursor:grabbing} -.aadl-viewport svg{width:100%;height:auto;transition:transform .15s ease;transform-origin:center center} -.aadl-viewport svg .node rect{rx:6;ry:6;filter:drop-shadow(0 1px 2px rgba(0,0,0,.08))} +.aadl-viewport svg{transform-origin:0 0;position:absolute;top:0;left:0} +.aadl-viewport svg .node rect,.aadl-viewport svg .node polygon,.aadl-viewport svg .node path,.aadl-viewport svg .node ellipse{filter:drop-shadow(0 1px 2px rgba(0,0,0,.08))} .aadl-viewport svg .node text{font-family:system-ui,-apple-system,sans-serif} .aadl-viewport svg .edge path{stroke-dasharray:none} .aadl-loading{color:var(--text-secondary);font-style:italic;padding:2rem;text-align:center} .aadl-error{color:var(--danger);font-style:italic;padding:1rem} +.aadl-analysis{border-top:1px solid var(--border);max-height:220px;overflow-y:auto;font-size:.78rem} +.aadl-analysis-header{display:flex;align-items:center;gap:.5rem;padding:.4rem 1rem; + background:var(--nav-bg);font-weight:600;font-size:.75rem;color:var(--text-secondary); + position:sticky;top:0;z-index:1;border-bottom:1px solid var(--border)} +.aadl-analysis-header .badge-count{display:inline-flex;align-items:center;justify-content:center; + min-width:1.3rem;height:1.3rem;border-radius:99px;font-size:.65rem;font-weight:700;padding:0 .3rem} +.badge-error{background:var(--danger);color:#fff} +.badge-warning{background:#e8a735;color:#fff} +.badge-info{background:var(--primary);color:#fff} +.aadl-diag{display:flex;align-items:baseline;gap:.5rem;padding:.3rem 1rem;border-bottom:1px solid var(--border)} +.aadl-diag:last-child{border-bottom:none} +.aadl-diag:hover{background:rgba(0,0,0,.03)} +.aadl-diag .sev{flex-shrink:0;font-size:.65rem;font-weight:700;text-transform:uppercase; + padding:.1rem .35rem;border-radius:var(--radius-sm);letter-spacing:.03em} +.sev-error{background:#fde8e8;color:var(--danger)} +.sev-warning{background:#fef3cd;color:#856404} +.sev-info{background:#d1ecf1;color:#0c5460} +.aadl-diag .diag-path{color:var(--text-secondary);font-family:var(--mono);font-size:.72rem;flex-shrink:0} +.aadl-diag .diag-msg{color:var(--text);flex:1} +.aadl-diag .diag-analysis{color:var(--text-secondary);font-size:.68rem;opacity:.7;flex-shrink:0} "#; // ── Pan/zoom JS ────────────────────────────────────────────────────────── @@ -1842,12 +1962,12 @@ async function initAadlDiagrams(){ controls.className = 'aadl-controls'; var btnOut = document.createElement('button'); btnOut.setAttribute('data-zoom','-1'); btnOut.title = 'Zoom out'; btnOut.textContent = '\u2212'; - var btnReset = document.createElement('button'); - btnReset.setAttribute('data-zoom','0'); btnReset.title = 'Reset zoom'; btnReset.textContent = '1:1'; + var btnFit = document.createElement('button'); + btnFit.setAttribute('data-zoom','0'); btnFit.title = 'Fit to view'; btnFit.textContent = 'Fit'; var btnIn = document.createElement('button'); btnIn.setAttribute('data-zoom','1'); btnIn.title = 'Zoom in'; btnIn.textContent = '+'; controls.appendChild(btnOut); - controls.appendChild(btnReset); + controls.appendChild(btnFit); controls.appendChild(btnIn); caption.appendChild(controls); container.appendChild(caption); @@ -1867,6 +1987,59 @@ async function initAadlDiagrams(){ container.appendChild(viewport); initZoomPan(viewport, imported); initDiagramInteraction(viewport); + + // Run analysis and display diagnostics panel + try { + var diags = renderer.analyze(root); + if(diags && diags.length > 0){ + var panel = document.createElement('div'); + panel.className = 'aadl-analysis'; + + // Header with severity counts + var hdr = document.createElement('div'); + hdr.className = 'aadl-analysis-header'; + hdr.textContent = 'Analysis '; + var errors = diags.filter(function(d){ return d.severity === 'error'; }).length; + var warnings = diags.filter(function(d){ return d.severity === 'warning'; }).length; + var infos = diags.filter(function(d){ return d.severity === 'info'; }).length; + if(errors > 0){ var b = document.createElement('span'); b.className = 'badge-count badge-error'; b.textContent = errors; hdr.appendChild(b); } + if(warnings > 0){ var b = document.createElement('span'); b.className = 'badge-count badge-warning'; b.textContent = warnings; hdr.appendChild(b); } + if(infos > 0){ var b = document.createElement('span'); b.className = 'badge-count badge-info'; b.textContent = infos; hdr.appendChild(b); } + panel.appendChild(hdr); + + // Sort: errors first, then warnings, then info + var order = {error:0, warning:1, info:2}; + diags.sort(function(a,b){ return (order[a.severity]||9) - (order[b.severity]||9); }); + + for(var i = 0; i < diags.length; i++){ + var d = diags[i]; + var row = document.createElement('div'); + row.className = 'aadl-diag'; + var sev = document.createElement('span'); + sev.className = 'sev sev-' + d.severity; + sev.textContent = d.severity; + row.appendChild(sev); + if(d.componentPath){ + var path = document.createElement('span'); + path.className = 'diag-path'; + path.textContent = d.componentPath; + row.appendChild(path); + } + var msg = document.createElement('span'); + msg.className = 'diag-msg'; + msg.textContent = d.message; + row.appendChild(msg); + var an = document.createElement('span'); + an.className = 'diag-analysis'; + an.textContent = d.analysisName; + row.appendChild(an); + panel.appendChild(row); + } + container.appendChild(panel); + } + } catch(analyzeErr){ + console.warn('AADL analysis error:', analyzeErr); + } } catch(err){ while(container.firstChild) container.removeChild(container.firstChild); var p = document.createElement('p'); @@ -1880,43 +2053,101 @@ async function initAadlDiagrams(){ } function initZoomPan(viewport, svg){ - var scale = 1, panX = 0, panY = 0, dragging = false, startX, startY; - function applyTransform(){ + var scale = 1, panX = 0, panY = 0; + var dragging = false, dragMoved = false, startMX, startMY, startPX, startPY; + var minScale = 0.05, maxScale = 12; + + function apply(){ svg.style.transform = 'translate(' + panX + 'px,' + panY + 'px) scale(' + scale + ')'; } + + // Get SVG intrinsic size + var svgW = parseFloat(svg.getAttribute('width')) || 400; + var svgH = parseFloat(svg.getAttribute('height')) || 300; + + // Fit diagram into viewport with padding + function fitToView(){ + var vw = viewport.clientWidth || 600; + var vh = viewport.clientHeight || 400; + var pad = 24; + scale = Math.min((vw - pad) / svgW, (vh - pad) / svgH, 3); + panX = (vw - svgW * scale) / 2; + panY = (vh - svgH * scale) / 2; + apply(); + } + + // Zoom toward a point in viewport coordinates + function zoomAt(mx, my, factor){ + var ns = Math.max(minScale, Math.min(maxScale, scale * factor)); + panX = mx - (mx - panX) * (ns / scale); + panY = my - (my - panY) * (ns / scale); + scale = ns; + apply(); + } + // Zoom buttons var controls = viewport.parentElement.querySelector('.aadl-controls'); if(controls){ controls.addEventListener('click', function(e){ var btn = e.target.closest('button'); if(!btn) return; - var z = parseInt(btn.getAttribute('data-zoom')); - if(z === 0){ scale = 1; panX = 0; panY = 0; } - else { scale = Math.max(0.2, Math.min(5, scale + z * 0.25)); } - applyTransform(); + var z = btn.getAttribute('data-zoom'); + if(z === '0'){ fitToView(); return; } + var vw = viewport.clientWidth || 600; + var vh = viewport.clientHeight || 400; + zoomAt(vw/2, vh/2, parseInt(z) > 0 ? 1.5 : 1/1.5); }); } - // Mouse wheel zoom + + // Mouse wheel zoom toward cursor viewport.addEventListener('wheel', function(e){ e.preventDefault(); - var delta = e.deltaY > 0 ? -0.1 : 0.1; - scale = Math.max(0.2, Math.min(5, scale + delta)); - applyTransform(); + var rect = viewport.getBoundingClientRect(); + var mx = e.clientX - rect.left; + var my = e.clientY - rect.top; + // Trackpad pinch sends ctrlKey + small delta; mouse wheel sends larger delta + var factor = e.ctrlKey + ? (e.deltaY > 0 ? 0.97 : 1.03) + : (e.deltaY > 0 ? 0.85 : 1/0.85); + zoomAt(mx, my, factor); }, {passive: false}); - // Pan via drag + + // Pan via drag (works anywhere, including on nodes) viewport.addEventListener('mousedown', function(e){ - if(e.target.closest('.node')){ return; } - dragging = true; startX = e.clientX - panX; startY = e.clientY - panY; + if(e.button !== 0) return; + dragging = true; dragMoved = false; + startMX = e.clientX; startMY = e.clientY; + startPX = panX; startPY = panY; viewport.classList.add('grabbing'); }); window.addEventListener('mousemove', function(e){ if(!dragging) return; - panX = e.clientX - startX; panY = e.clientY - startY; - applyTransform(); + var dx = e.clientX - startMX, dy = e.clientY - startMY; + if(!dragMoved && Math.abs(dx) + Math.abs(dy) > 4) dragMoved = true; + if(dragMoved){ + panX = startPX + dx; + panY = startPY + dy; + apply(); + } }); window.addEventListener('mouseup', function(){ - dragging = false; viewport.classList.remove('grabbing'); + if(!dragging) return; + dragging = false; + viewport.classList.remove('grabbing'); + // Mark viewport so node click handler can distinguish click from drag + if(dragMoved) viewport.setAttribute('data-dragged',''); + else viewport.removeAttribute('data-dragged'); + }); + + // Double-click to zoom in toward cursor + viewport.addEventListener('dblclick', function(e){ + e.preventDefault(); + var rect = viewport.getBoundingClientRect(); + zoomAt(e.clientX - rect.left, e.clientY - rect.top, 2); }); + + // Initial fit + fitToView(); } function initDiagramInteraction(viewport){ @@ -1924,9 +2155,24 @@ function initDiagramInteraction(viewport){ nodes.forEach(function(node){ node.style.cursor = 'pointer'; node.addEventListener('click', function(e){ + // Skip if this was a drag gesture, not a click + if(viewport.hasAttribute('data-dragged')){ + viewport.removeAttribute('data-dragged'); + return; + } e.stopPropagation(); var id = node.getAttribute('data-id'); - if(id) htmx.ajax('GET', '/artifacts/' + encodeURIComponent(id), {target:'#content'}); + if(!id) return; + fetch('/artifacts/' + encodeURIComponent(id) + '/preview', {headers:{'HX-Request':'true'}}) + .then(function(r){ + if(r.ok) return r.text(); + return null; + }) + .then(function(html){ + if(html && html.indexOf('not found') === -1 && html.indexOf('Not Found') === -1){ + htmx.ajax('GET', '/artifacts/' + encodeURIComponent(id), {target:'#content'}); + } + }); }); }); } @@ -1935,19 +2181,86 @@ window.highlightAadlNodes = function(artifactIds){ var nodes = document.querySelectorAll('.aadl-diagram svg .node'); nodes.forEach(function(node){ var id = node.getAttribute('data-id'); - var rect = node.querySelector('rect'); - if(!rect) return; + // Shape may be rect, polygon, path, or ellipse depending on AADL category + var shape = node.querySelector('rect, polygon, path, ellipse'); + if(!shape) return; if(artifactIds.indexOf(id) !== -1){ - rect.setAttribute('stroke','#f0c040'); - rect.setAttribute('stroke-width','3'); + shape.setAttribute('stroke','#f0c040'); + shape.setAttribute('stroke-width','3'); } else { - rect.setAttribute('stroke',''); - rect.setAttribute('stroke-width',''); + shape.setAttribute('stroke',''); + shape.setAttribute('stroke-width',''); } }); }; document.body.addEventListener('htmx:afterSwap', initAadlDiagrams); + +// ── Table sort & filter ────────────────────────────────── +function initTables(){ + var tables = document.querySelectorAll('#content table'); + tables.forEach(function(table){ + if(table.classList.contains('tbl-enhanced')) return; + var thead = table.querySelector('thead'); + var tbody = table.querySelector('tbody'); + if(!thead || !tbody) return; + var rows = tbody.querySelectorAll('tr'); + if(rows.length < 3) return; // skip tiny tables + table.classList.add('tbl-enhanced'); + + // Add filter input above table + var wrap = document.createElement('div'); + wrap.className = 'tbl-filter-wrap'; + var inp = document.createElement('input'); + inp.type = 'text'; + inp.placeholder = 'Filter rows\u2026'; + inp.className = 'tbl-filter'; + inp.addEventListener('input', function(){ + var q = inp.value.toLowerCase(); + tbody.querySelectorAll('tr').forEach(function(row){ + row.style.display = row.textContent.toLowerCase().indexOf(q) !== -1 ? '' : 'none'; + }); + }); + wrap.appendChild(inp); + table.parentNode.insertBefore(wrap, table); + + // Sortable headers + var ths = thead.querySelectorAll('th'); + ths.forEach(function(th, colIdx){ + th.style.cursor = 'pointer'; + th.style.userSelect = 'none'; + th.title = 'Click to sort'; + var arrow = document.createElement('span'); + arrow.className = 'tbl-sort-arrow'; + arrow.textContent = ''; + th.appendChild(arrow); + var asc = true; + th.addEventListener('click', function(){ + // Reset all arrows + ths.forEach(function(h){ + var a = h.querySelector('.tbl-sort-arrow'); + if(a) a.textContent = ''; + }); + var rowsArr = Array.from(tbody.querySelectorAll('tr')); + rowsArr.sort(function(a, b){ + var at = (a.children[colIdx] || {}).textContent || ''; + var bt = (b.children[colIdx] || {}).textContent || ''; + // Try numeric sort first + var an = parseFloat(at), bn = parseFloat(bt); + if(!isNaN(an) && !isNaN(bn)){ + return asc ? an - bn : bn - an; + } + return asc ? at.localeCompare(bt) : bt.localeCompare(at); + }); + rowsArr.forEach(function(r){ tbody.appendChild(r); }); + arrow.textContent = asc ? ' \u25B2' : ' \u25BC'; + asc = !asc; + }); + }); + }); +} +document.body.addEventListener('htmx:afterSwap', initTables); +document.addEventListener('DOMContentLoaded', initTables); "#; @@ -2080,6 +2393,13 @@ fn page_layout(content: &str, state: &AppState) -> Html { +
@@ -2155,7 +2475,8 @@ async fn index( if let Some(ref goto) = params.goto { let placeholder = format!( "
\ - ", + ", + html_escape(goto), html_escape(goto), html_escape(goto) ); @@ -2278,7 +2599,7 @@ fn stats_partial(state: &AppState) -> String { html.push_str("

Orphan Artifacts (no links)

"); for id in &orphans { html.push_str(&format!( - "" + "" )); } html.push_str("
ID
{id}
{id}
"); @@ -2311,7 +2632,7 @@ fn stats_partial(state: &AppState) -> String { \ \ \ - \ View full coverage report →\ ", @@ -2362,7 +2683,7 @@ fn stats_partial(state: &AppState) -> String { } html.push_str(""); html.push_str( - "\ View all test runs →", ); @@ -2387,7 +2708,7 @@ fn stats_partial(state: &AppState) -> String {
", ); html.push_str(&format!( - "\
Verification
\ @@ -2395,7 +2716,7 @@ fn stats_partial(state: &AppState) -> String {
", )); html.push_str(&format!( - "\
Documents
\ @@ -2404,7 +2725,7 @@ fn stats_partial(state: &AppState) -> String { doc_store.len(), )); html.push_str( - "
\
Traceability Graph
\ @@ -2446,7 +2767,7 @@ async fn artifacts_list(State(state): State) -> Html { _ => format!("{status}"), }; html.push_str(&format!( - "
{}\ + "{}\ {}\ {}\ {}\ @@ -2591,6 +2912,10 @@ async fn artifact_detail(State(state): State, Path(id): Path linkify_source_refs(&html_escape(s)), other => html_escape(&format!("{other:?}")), @@ -2599,13 +2924,34 @@ async fn artifact_detail(State(state): State, Path(id): Path
"); + // Diagram field — render mermaid or AADL diagram if present + if let Some(serde_yaml::Value::String(diagram)) = artifact.fields.get("diagram") { + html.push_str("
"); + html.push_str("

Diagram

"); + let trimmed = diagram.trim(); + if trimmed.starts_with("root:") { + // AADL diagram + let root = trimmed.strip_prefix("root:").unwrap_or("").trim(); + html.push_str(&format!( + "

Loading AADL diagram...

", + html_escape(root) + )); + } else { + // Treat as mermaid + html.push_str("
");
+            html.push_str(&html_escape(trimmed));
+            html.push_str("
"); + } + html.push_str("
"); + } + // Forward links if !artifact.links.is_empty() { html.push_str("

Outgoing Links

"); for link in &artifact.links { let target_display = if store.contains(&link.target) { format!( - "{}", + "{}", html_escape(&link.target), html_escape(&link.target) ) @@ -2632,7 +2978,7 @@ async fn artifact_detail(State(state): State, Path(id): Path\ - ", + ", html_escape(label), html_escape(&bl.source), html_escape(&bl.source) @@ -2667,8 +3013,8 @@ async fn artifact_detail(State(state): State, Path(id): Path - Show in graph - ← Back to artifacts + Show in graph + ← Back to artifacts "##, id_esc = html_escape(&id), )); @@ -3040,9 +3386,9 @@ async fn artifact_graph( )); html.push_str(&format!( - r##"

← Back to {id_esc} + r##"

← Back to {id_esc}  |  - Open in full graph

"##, + Open in full graph

"##, id_esc = html_escape(&id), )); @@ -3198,7 +3544,7 @@ async fn validate_view(State(state): State) -> Html { let art_id = d.artifact_id.as_deref().unwrap_or("-"); let art_link = if d.artifact_id.is_some() && state.store.contains(art_id) { format!( - "{art}", + "{art}", art = html_escape(art_id) ) } else { @@ -3323,7 +3669,7 @@ async fn matrix_view( .iter() .map(|t| { format!( - "{}", + "{}", html_escape(&t.id), html_escape(&t.id) ) @@ -3332,7 +3678,7 @@ async fn matrix_view( .join(", ") }; html.push_str(&format!( - "
", + "", html_escape(&row.source_id), html_escape(&row.source_id), targets @@ -3452,7 +3798,7 @@ async fn coverage_view(State(state): State) -> Html { for id in &entry.uncovered_ids { let title = state.store.get(id).map(|a| a.title.as_str()).unwrap_or("-"); html.push_str(&format!( - "\ + "\ ", id_esc = html_escape(id), title_esc = html_escape(title), @@ -3493,7 +3839,7 @@ async fn documents_list(State(state): State) -> Html { _ => format!("{status}"), }; html.push_str(&format!( - "\ + "\ \ \ \ @@ -3578,7 +3924,21 @@ async fn document_detail(State(state): State, Path(id): Path
"); - let body_html = document::render_to_html(doc, |aid| store.contains(aid)); + let body_html = document::render_to_html( + doc, + |aid| store.contains(aid), + |aid| { + store.get(aid).map(|a| document::ArtifactInfo { + id: a.id.clone(), + title: a.title.clone(), + art_type: a.artifact_type.clone(), + status: a.status.clone().unwrap_or_default(), + description: a.description.clone().unwrap_or_default(), + }) + }, + ); + // Rewrite relative image src to serve through /docs-asset/ + let body_html = rewrite_image_paths(&body_html); html.push_str(&body_html); html.push_str("
"); @@ -3608,7 +3968,7 @@ async fn document_detail(State(state): State, Path(id): Path
\ + "\ \ \ ", @@ -3631,7 +3991,7 @@ async fn document_detail(State(state): State, Path(id): Path← Back to documents

", + "

← Back to documents

", ); Html(html) @@ -4086,7 +4446,7 @@ async fn verification_view(State(state): State) -> Html { html.push_str("
"); html.push_str(&format!( "\ - {id}\ + {id}\ {title}\ {status}\ {coverage_badge}", @@ -4120,7 +4480,7 @@ async fn verification_view(State(state): State) -> Html { for v in &row.verifiers { html.push_str(&format!( "

\ - {id} \ + {id} \ {type_badge} \ {method} \ — {title}", @@ -4274,7 +4634,7 @@ fn stpa_partial(state: &AppState) -> Html { html.push_str(" "); html.push_str(&badge_for_type("loss")); html.push_str(&format!( - " {id}\ + " {id}\ {title}", id = html_escape(loss_id), title = html_escape(&loss.title), @@ -4299,7 +4659,7 @@ fn stpa_partial(state: &AppState) -> Html { html.push_str("leads-to-loss"); html.push_str(&badge_for_type(&hazard.artifact_type)); html.push_str(&format!( - " {id}\ + " {id}\ {title}", id = html_escape(hazard_id), title = html_escape(&hazard.title), @@ -4330,7 +4690,7 @@ fn stpa_partial(state: &AppState) -> Html { html.push_str(&format!( "

\ prevents{badge}\ - {id}\ + {id}\ {title}\
", badge = badge_for_type("system-constraint"), @@ -4362,7 +4722,7 @@ fn stpa_partial(state: &AppState) -> Html { html.push_str("leads-to-hazard"); html.push_str(&badge_for_type("uca")); html.push_str(&format!( - " {id}\ + " {id}\ {title}", id = html_escape(uca_id), title = html_escape(&uca.title), @@ -4384,7 +4744,7 @@ fn stpa_partial(state: &AppState) -> Html { html.push_str(&format!( "
\ inverts-uca{badge}\ - {id}\ + {id}\ {title}\
", badge = badge_for_type("controller-constraint"), @@ -4402,7 +4762,7 @@ fn stpa_partial(state: &AppState) -> Html { html.push_str(&format!( "
\ caused-by-uca{badge}\ - {id}\ + {id}\ {title}\
", badge = badge_for_type("loss-scenario"), @@ -4520,7 +4880,7 @@ fn stpa_partial(state: &AppState) -> Html { .iter() .map(|h| { format!( - "{id}", id = html_escape(h), ) @@ -4530,14 +4890,14 @@ fn stpa_partial(state: &AppState) -> Html { "-".to_string() } else { format!( - "{id}", id = html_escape(&row.control_action), ) }; html.push_str(&format!( "
\ - \ + \ \ \ \ @@ -4629,7 +4989,7 @@ async fn results_view(State(state): State) -> Html { html.push_str(&format!( "\ - \ + \ \ \ \ @@ -4726,7 +5086,7 @@ async fn result_detail( html.push_str(&format!( "\ - \ + \ \ \ \ @@ -4741,7 +5101,7 @@ async fn result_detail( html.push_str("
TypeTarget
{}{}
{}
{}{}
{}{}
{id_esc}
{id_esc}{title_esc}
{}
{}{}{}{}{}
{}{}{}{}
{id}{id}{ca}{type_badge}{title}
{id} {status_badge}{id} {status_badge}{ts}{src}{env}
{aid}{aid}{title}{status_badge}{duration}
"); html.push_str( - "

← Back to results

", + "

← Back to results

", ); Html(html) @@ -5008,7 +5368,7 @@ async fn source_file_view( } if metadata.is_dir() { return Html(format!( - "

Directory

{} is a directory. Back to tree

", + "

Directory

{} is a directory. Back to tree

", html_escape(rel_path) )); } @@ -5016,7 +5376,7 @@ async fn source_file_view( let file_size = metadata.len(); if file_size > SOURCE_MAX_SIZE { return Html(format!( - "

File Too Large

{} is {} which exceeds the 100 KB limit.

← Back to files

", + "

File Too Large

{} is {} which exceeds the 100 KB limit.

← Back to files

", html_escape(rel_path), format_size(file_size) )); @@ -5075,19 +5435,49 @@ async fn source_file_view( let is_yaml = file_name.ends_with(".yaml") || file_name.ends_with(".yml"); let is_markdown = file_name.ends_with(".md"); let is_rust = file_name.ends_with(".rs"); + let is_toml = file_name.ends_with(".toml"); + let is_shell = file_name.ends_with(".sh"); + let is_aadl = file_name.ends_with(".aadl"); let artifact_ids = collect_artifact_ids(store); + let file_lang = if is_yaml { + "yaml" + } else if is_rust { + "rust" + } else if is_toml { + "toml" + } else if is_shell { + "bash" + } else if is_aadl { + "yaml" // AADL has similar key: value structure + } else { + "" + }; + if is_markdown && content.starts_with("---") { if let Ok(doc) = rivet_core::document::parse_document(&content, Some(&full_path)) { html.push_str("
"); - let body_html = document::render_to_html(&doc, |aid| store.contains(aid)); + let body_html = document::render_to_html( + &doc, + |aid| store.contains(aid), + |aid| { + store.get(aid).map(|a| document::ArtifactInfo { + id: a.id.clone(), + title: a.title.clone(), + art_type: a.artifact_type.clone(), + status: a.status.clone().unwrap_or_default(), + description: a.description.clone().unwrap_or_default(), + }) + }, + ); + let body_html = rewrite_image_paths(&body_html); html.push_str(&body_html); html.push_str("
"); } else { - render_code_block(&content, &artifact_ids, is_yaml, is_rust, &mut html); + render_code_block(&content, &artifact_ids, file_lang, &mut html); } } else { - render_code_block(&content, &artifact_ids, is_yaml, is_rust, &mut html); + render_code_block(&content, &artifact_ids, file_lang, &mut html); } let refs = artifacts_referencing_file(store, rel_path); @@ -5341,24 +5731,198 @@ fn syntax_highlight_line(line: &str, lang: &str) -> String { match lang { "yaml" | "yml" => highlight_yaml_line(line), "bash" | "sh" | "shell" => highlight_bash_line(line), + "rust" | "rs" => highlight_rust_line(line), + "toml" => highlight_toml_line(line), _ => html_escape(line), } } +/// Syntax-highlight a single line of Rust source. +fn highlight_rust_line(line: &str) -> String { + let trimmed = line.trim_start(); + if trimmed.is_empty() { + return html_escape(line); + } + // Full-line comments + if trimmed.starts_with("//") { + let indent = &line[..line.len() - trimmed.len()]; + return format!( + "{}{}", + html_escape(indent), + html_escape(trimmed) + ); + } + // Attributes: #[...] or #![...] + if trimmed.starts_with("#[") || trimmed.starts_with("#![") { + let indent = &line[..line.len() - trimmed.len()]; + return format!( + "{}{}", + html_escape(indent), + html_escape(trimmed) + ); + } + let escaped = html_escape(line); + let mut out = String::with_capacity(escaped.len() * 2); + let chars: Vec = line.chars().collect(); + let len = chars.len(); + let mut i = 0; + while i < len { + let ch = chars[i]; + // String literals + if ch == '"' { + let start = i; + i += 1; + while i < len && chars[i] != '"' { + if chars[i] == '\\' { + i += 1; + } + i += 1; + } + if i < len { + i += 1; + } + let s: String = chars[start..i].iter().collect(); + out.push_str(&format!("{}", html_escape(&s))); + continue; + } + // Char literals + if ch == '\'' && i + 2 < len && chars[i + 2] == '\'' { + let s: String = chars[i..i + 3].iter().collect(); + out.push_str(&format!("{}", html_escape(&s))); + i += 3; + continue; + } + // Line comments (mid-line) + if ch == '/' && i + 1 < len && chars[i + 1] == '/' { + let s: String = chars[i..].iter().collect(); + out.push_str(&format!( + "{}", + html_escape(&s) + )); + break; + } + // Numbers + if ch.is_ascii_digit() && (i == 0 || !chars[i - 1].is_alphanumeric()) { + let start = i; + while i < len && (chars[i].is_ascii_alphanumeric() || chars[i] == '_' || chars[i] == '.') { + i += 1; + } + let s: String = chars[start..i].iter().collect(); + out.push_str(&format!("{}", html_escape(&s))); + continue; + } + // Identifiers and keywords + if ch.is_ascii_alphabetic() || ch == '_' { + let start = i; + while i < len && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') { + i += 1; + } + let word: String = chars[start..i].iter().collect(); + // Check for macro invocation: word! + if i < len && chars[i] == '!' && !matches!(word.as_str(), "if" | "else" | "return" | "break" | "continue") { + out.push_str(&format!( + "{}!", + html_escape(&word) + )); + i += 1; + continue; + } + match word.as_str() { + "fn" | "let" | "mut" | "pub" | "use" | "mod" | "struct" | "enum" + | "impl" | "trait" | "const" | "static" | "type" | "where" | "match" + | "if" | "else" | "for" | "while" | "loop" | "return" | "break" + | "continue" | "async" | "await" | "move" | "ref" | "self" | "super" + | "crate" | "unsafe" | "extern" | "dyn" | "as" | "in" | "true" + | "false" | "Self" | "None" | "Some" | "Ok" | "Err" => { + out.push_str(&format!( + "{}", + html_escape(&word) + )); + } + _ if word.chars().next().is_some_and(|c| c.is_ascii_uppercase()) => { + out.push_str(&format!( + "{}", + html_escape(&word) + )); + } + _ => out.push_str(&html_escape(&word)), + } + continue; + } + // Punctuation: &, ::, ->, =>, etc. + out.push_str(&html_escape(&ch.to_string())); + i += 1; + } + out +} + +/// Syntax-highlight a single line of TOML. +fn highlight_toml_line(line: &str) -> String { + let trimmed = line.trim_start(); + if trimmed.is_empty() { + return html_escape(line); + } + let indent = &line[..line.len() - trimmed.len()]; + // Comments + if trimmed.starts_with('#') { + return format!( + "{}{}", + html_escape(indent), + html_escape(trimmed) + ); + } + // Section headers [foo] or [[foo]] + if trimmed.starts_with('[') { + return format!( + "{}{}", + html_escape(indent), + html_escape(trimmed) + ); + } + // key = value + if let Some(eq_pos) = trimmed.find('=') { + let key = &trimmed[..eq_pos].trim_end(); + let rest = &trimmed[eq_pos..]; + return format!( + "{}{}{}", + html_escape(indent), + html_escape(key), + highlight_toml_value(rest) + ); + } + html_escape(line) +} + +fn highlight_toml_value(s: &str) -> String { + let trimmed = s.strip_prefix('=').unwrap_or(s); + let val = trimmed.trim(); + if val.starts_with('"') || val.starts_with('\'') { + return format!( + "= {}", + html_escape(val) + ); + } + if val == "true" || val == "false" { + return format!( + "= {}", + val + ); + } + if val.chars().next().is_some_and(|c| c.is_ascii_digit()) { + return format!( + "= {}", + html_escape(val) + ); + } + format!("= {}", html_escape(trimmed)) +} + fn render_code_block( content: &str, artifact_ids: &std::collections::HashSet, - is_yaml: bool, - is_rust: bool, + lang: &str, html: &mut String, ) { - let lang = if is_yaml { - "yaml" - } else if is_rust { - "rust" - } else { - "" - }; html.push_str("
"); for (i, line) in content.lines().enumerate() { let line_num = i + 1; @@ -5375,7 +5939,7 @@ fn render_code_block( html_escape(line) }; // Then overlay artifact links on top - let display_line = if is_yaml || is_rust { + let display_line = if !lang.is_empty() { let mut result = highlighted; let mut ids: Vec<&String> = artifact_ids .iter() @@ -5447,14 +6011,25 @@ fn load_store_from_git_ref(pp: &std::path::Path, gr: &str) -> Result Result Stri // Leaf node — no expanding format!( "
{edge_label}{badge} \ - {escaped_id} \ + {escaped_id} \ {escaped_title}{status_badge}\
", + "", html_escape(id), html_escape(id), html_escape(&a.title) @@ -6245,7 +6821,7 @@ async fn traceability_view( if children.is_empty() { html.push_str(&format!( "
{badge} \ - {escaped_id} \ + {escaped_id} \ {title}{status_badge} \ (no inbound links)\
{}{}
{}{}
"); for cell in &cells { - let text = resolve_inline(cell, &artifact_exists); + let text = resolve_inline(cell, &artifact_exists, &artifact_info); html.push_str(&format!("")); } html.push_str("\n"); @@ -464,7 +488,7 @@ pub fn render_to_html(doc: &Document, artifact_exists: impl Fn(&str) -> bool) -> } else if table_header_done { html.push_str(""); for cell in &cells { - let text = resolve_inline(cell, &artifact_exists); + let text = resolve_inline(cell, &artifact_exists, &artifact_info); html.push_str(&format!("")); } html.push_str("\n"); @@ -495,7 +519,7 @@ pub fn render_to_html(doc: &Document, artifact_exists: impl Fn(&str) -> bool) -> html.push_str("
"); in_blockquote = true; } - let text = resolve_inline(bq_text, &artifact_exists); + let text = resolve_inline(bq_text, &artifact_exists, &artifact_info); html.push_str(&format!("

{text}

")); continue; } @@ -523,7 +547,7 @@ pub fn render_to_html(doc: &Document, artifact_exists: impl Fn(&str) -> bool) -> html.push_str("
{text}
{text}