Skip to main content

ta_changeset/output_adapters/
html.rs

1//! html.rs — HTML output adapter with JavaScript-free progressive disclosure.
2
3use crate::error::ChangeSetError;
4use crate::output_adapters::{matches_file_filters, DetailLevel, OutputAdapter, RenderContext};
5use crate::pr_package::{Artifact, ArtifactDisposition, ChangeType};
6
7#[derive(Default)]
8pub struct HtmlAdapter {}
9
10impl HtmlAdapter {
11    pub fn new() -> Self {
12        Self {}
13    }
14
15    fn disposition_badge(&self, disposition: &ArtifactDisposition) -> &str {
16        match disposition {
17            ArtifactDisposition::Pending => r#"<span class="status pending">pending</span>"#,
18            ArtifactDisposition::Approved => r#"<span class="status approved">approved</span>"#,
19            ArtifactDisposition::Rejected => r#"<span class="status denied">rejected</span>"#,
20            ArtifactDisposition::Discuss => r#"<span class="status discuss">discuss</span>"#,
21        }
22    }
23
24    fn change_badge(&self, change_type: &ChangeType) -> &str {
25        match change_type {
26            ChangeType::Add => r#"<span class="badge add">+</span>"#,
27            ChangeType::Modify => r#"<span class="badge modify">~</span>"#,
28            ChangeType::Delete => r#"<span class="badge delete">-</span>"#,
29            ChangeType::Rename => r#"<span class="badge rename">&gt;</span>"#,
30        }
31    }
32
33    fn css(&self) -> &str {
34        r#"
35        <style>
36            body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 1200px; margin: 0 auto; padding: 20px; line-height: 1.6; }
37            h1, h2, h3 { color: #333; }
38            .header { background: #f5f5f5; padding: 20px; border-radius: 8px; margin-bottom: 30px; }
39            .status { display: inline-block; padding: 4px 12px; border-radius: 4px; font-weight: 600; text-transform: uppercase; font-size: 12px; }
40            .status.pending { background: #fef3c7; color: #92400e; }
41            .status.approved { background: #d1fae5; color: #065f46; }
42            .status.denied { background: #fee2e2; color: #991b1b; }
43            .status.discuss { background: #dbeafe; color: #1e40af; }
44            .artifact { background: white; border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
45            .badge { display: inline-block; width: 24px; height: 24px; text-align: center; border-radius: 4px; font-weight: 700; margin-right: 8px; }
46            .badge.add { background: #d1fae5; color: #065f46; }
47            .badge.modify { background: #fef3c7; color: #92400e; }
48            .badge.delete { background: #fee2e2; color: #991b1b; }
49            .badge.rename { background: #dbeafe; color: #1e40af; }
50            details { margin-top: 15px; }
51            summary { cursor: pointer; font-weight: 600; color: #4b5563; user-select: none; }
52            summary:hover { color: #1f2937; }
53            pre { background: #f9fafb; padding: 15px; border-radius: 4px; overflow-x: auto; }
54            code { font-family: 'Monaco', 'Menlo', monospace; font-size: 13px; }
55            .diff-add { color: #065f46; }
56            .diff-del { color: #991b1b; }
57            .meta { color: #6b7280; font-size: 14px; margin-top: 10px; }
58            .tags { display: flex; gap: 8px; margin-top: 10px; }
59            .tag { background: #ede9fe; color: #5b21b6; padding: 4px 12px; border-radius: 12px; font-size: 12px; }
60            .decision-log { background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
61            .decision-entry { border-left: 3px solid #0ea5e9; padding-left: 12px; margin: 12px 0; }
62            .decision-title { font-weight: 600; color: #0c4a6e; }
63            .decision-alts { color: #6b7280; font-size: 14px; }
64            .decision-rationale { color: #374151; margin-top: 6px; }
65            .confidence { background: #e0f2fe; color: #0369a1; padding: 2px 8px; border-radius: 10px; font-size: 12px; margin-left: 8px; }
66        </style>
67        <script>
68        // Persist section open/closed state in localStorage.
69        document.addEventListener('DOMContentLoaded', function() {
70            document.querySelectorAll('details').forEach(function(el) {
71                var key = 'ta-draft-' + (el.dataset.key || el.querySelector('summary').textContent.trim().slice(0,40));
72                if (localStorage.getItem(key) === 'open') { el.open = true; }
73                el.addEventListener('toggle', function() {
74                    localStorage.setItem(key, el.open ? 'open' : 'closed');
75                });
76            });
77        });
78        </script>
79        "#
80    }
81}
82
83impl OutputAdapter for HtmlAdapter {
84    fn render(&self, ctx: &RenderContext) -> Result<String, ChangeSetError> {
85        use crate::output_adapters::SectionFilter;
86
87        let pkg = ctx.package;
88        let mut html = String::from("<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n");
89        html.push_str(&format!("<title>Draft: {}</title>\n", pkg.package_id));
90        html.push_str(self.css());
91        html.push_str("</head>\n<body>\n");
92
93        // Section filtering: show only the requested section.
94        let show_summary =
95            ctx.section_filter.is_none() || ctx.section_filter == Some(SectionFilter::Summary);
96        let show_decisions =
97            ctx.section_filter.is_none() || ctx.section_filter == Some(SectionFilter::Decisions);
98        let show_files =
99            ctx.section_filter.is_none() || ctx.section_filter == Some(SectionFilter::Files);
100
101        if show_summary {
102            // Header
103            html.push_str("<div class=\"header\">\n");
104            html.push_str("<h1>Draft</h1>\n");
105            html.push_str(&format!("<p><strong>ID:</strong> {}</p>\n", pkg.package_id));
106            html.push_str(&format!(
107                "<p><strong>Status:</strong> <span class=\"status {}\">{}</span></p>\n",
108                pkg.status, pkg.status
109            ));
110            html.push_str(&format!(
111                "<p><strong>Goal:</strong> {}</p>\n",
112                pkg.goal.title
113            ));
114            html.push_str(&format!(
115                "<p><strong>Created:</strong> {}</p>\n",
116                pkg.created_at.format("%Y-%m-%d %H:%M:%S")
117            ));
118            html.push_str("</div>\n");
119
120            // Summary
121            html.push_str("<details open data-key=\"summary\">\n<summary><h2 style=\"display:inline\">Summary</h2></summary>\n");
122            html.push_str(&format!(
123                "<p><strong>What changed:</strong> {}</p>\n",
124                pkg.summary.what_changed
125            ));
126            html.push_str(&format!(
127                "<p><strong>Why:</strong> {}</p>\n",
128                pkg.summary.why
129            ));
130            html.push_str(&format!(
131                "<p><strong>Impact:</strong> {}</p>\n",
132                pkg.summary.impact
133            ));
134            html.push_str("</details>\n");
135        }
136
137        // Agent Decision Log (v0.14.7)
138        if show_decisions && !pkg.agent_decision_log.is_empty() {
139            html.push_str(&format!(
140                "<details open data-key=\"decisions\">\n<summary><h2 style=\"display:inline\">Agent Decision Log ({} decisions)</h2></summary>\n",
141                pkg.agent_decision_log.len()
142            ));
143            html.push_str("<div class=\"decision-log\">\n");
144            for entry in &pkg.agent_decision_log {
145                html.push_str("<details open class=\"decision-entry\">\n");
146                let confidence_html = entry
147                    .confidence
148                    .map(|c| {
149                        format!(
150                            r#"<span class="confidence">{:.0}% confidence</span>"#,
151                            c * 100.0
152                        )
153                    })
154                    .unwrap_or_default();
155                html.push_str(&format!(
156                    "<summary class=\"decision-title\">▸ {}{}</summary>\n",
157                    entry.decision, confidence_html
158                ));
159                let alts: Vec<&str> = entry
160                    .alternatives
161                    .iter()
162                    .map(String::as_str)
163                    .chain(
164                        entry
165                            .alternatives_considered
166                            .iter()
167                            .map(|a| a.description.as_str()),
168                    )
169                    .collect();
170                if !alts.is_empty() {
171                    html.push_str(&format!(
172                        "<p class=\"decision-alts\"><strong>Alternatives:</strong> {}</p>\n",
173                        alts.join(", ")
174                    ));
175                }
176                html.push_str(&format!(
177                    "<p class=\"decision-rationale\"><strong>Rationale:</strong> {}</p>\n",
178                    entry.rationale
179                ));
180                html.push_str("</details>\n");
181            }
182            html.push_str("</div>\n</details>\n");
183        }
184
185        if show_files {
186            let artifacts: Vec<&Artifact> = pkg
187                .changes
188                .artifacts
189                .iter()
190                .filter(|a| matches_file_filters(&a.resource_uri, &ctx.file_filters))
191                .collect();
192
193            html.push_str(&format!(
194                "<details open data-key=\"files\">\n<summary><h2 style=\"display:inline\">Changed Files ({})</h2></summary>\n",
195                artifacts.len()
196            ));
197
198            for artifact in &artifacts {
199                // Each file is wrapped in a collapsible <details>
200                html.push_str(&format!(
201                    "<details data-key=\"file-{}\">\n",
202                    artifact.resource_uri.replace('/', "-")
203                ));
204                html.push_str(&format!(
205                    "<summary class=\"artifact\">{} {} <strong>{}</strong>",
206                    self.change_badge(&artifact.change_type),
207                    self.disposition_badge(&artifact.disposition),
208                    artifact.resource_uri
209                ));
210
211                if let Some(tiers) = &artifact.explanation_tiers {
212                    html.push_str(&format!(" — <em>{}</em>", tiers.summary));
213                } else if let Some(rationale) = &artifact.rationale {
214                    html.push_str(&format!(" — <em>{}</em>", rationale));
215                }
216                html.push_str("</summary>\n");
217
218                if let Some(tiers) = &artifact.explanation_tiers {
219                    if ctx.detail_level == DetailLevel::Medium
220                        || ctx.detail_level == DetailLevel::Full
221                    {
222                        html.push_str(&format!("<p>{}</p>\n", tiers.explanation));
223                        if !tiers.tags.is_empty() {
224                            html.push_str("<div class=\"tags\">");
225                            for tag in &tiers.tags {
226                                html.push_str(&format!("<span class=\"tag\">{}</span>", tag));
227                            }
228                            html.push_str("</div>\n");
229                        }
230                    }
231                }
232
233                // Diffs are always shown in a nested collapsible (collapsed by default)
234                if let Some(provider) = ctx.diff_provider {
235                    if let Ok(diff) = provider.get_diff(&artifact.diff_ref) {
236                        html.push_str("<details data-key=\"diff-");
237                        html.push_str(&artifact.resource_uri.replace('/', "-"));
238                        html.push_str("\">\n<summary>View diff</summary>\n<pre><code>");
239                        for line in diff.lines() {
240                            if line.starts_with('+') && !line.starts_with("+++") {
241                                html.push_str(&format!(
242                                    "<span class=\"diff-add\">{}</span>\n",
243                                    line
244                                ));
245                            } else if line.starts_with('-') && !line.starts_with("---") {
246                                html.push_str(&format!(
247                                    "<span class=\"diff-del\">{}</span>\n",
248                                    line
249                                ));
250                            } else {
251                                html.push_str(&format!("{}\n", line));
252                            }
253                        }
254                        html.push_str("</code></pre>\n</details>\n");
255                    }
256                }
257
258                html.push_str("</details>\n");
259            }
260            html.push_str("</details>\n");
261        }
262
263        html.push_str(&format!(
264            "<div class=\"meta\">Generated by Trusted Autonomy v{}</div>\n",
265            pkg.package_version
266        ));
267        html.push_str("</body>\n</html>");
268
269        Ok(html)
270    }
271
272    fn name(&self) -> &str {
273        "html"
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn disposition_badge_renders_all_variants() {
283        let adapter = HtmlAdapter::new();
284        assert!(adapter
285            .disposition_badge(&ArtifactDisposition::Pending)
286            .contains("pending"));
287        assert!(adapter
288            .disposition_badge(&ArtifactDisposition::Approved)
289            .contains("approved"));
290        assert!(adapter
291            .disposition_badge(&ArtifactDisposition::Rejected)
292            .contains("denied"));
293        assert!(adapter
294            .disposition_badge(&ArtifactDisposition::Discuss)
295            .contains("discuss"));
296    }
297
298    #[test]
299    fn css_includes_discuss_status_class() {
300        let adapter = HtmlAdapter::new();
301        let css = adapter.css();
302        assert!(css.contains(".status.discuss"));
303        assert!(css.contains("#dbeafe"));
304    }
305
306    #[test]
307    fn html_output_includes_disposition_badges() {
308        use crate::draft_package::*;
309        use crate::output_adapters::RenderContext;
310        use chrono::Utc;
311        use uuid::Uuid;
312
313        let mut pkg = DraftPackage {
314            package_version: "1.0.0".to_string(),
315            package_id: Uuid::nil(),
316            created_at: Utc::now(),
317            goal: Goal {
318                goal_id: "g1".to_string(),
319                title: "Test".to_string(),
320                objective: "Test".to_string(),
321                success_criteria: vec![],
322                constraints: vec![],
323                parent_goal_title: None,
324            },
325            iteration: Iteration {
326                iteration_id: "i1".to_string(),
327                sequence: 1,
328                workspace_ref: WorkspaceRef {
329                    ref_type: "staging_dir".to_string(),
330                    ref_name: "staging/g1/1".to_string(),
331                    base_ref: None,
332                },
333            },
334            agent_identity: AgentIdentity {
335                agent_id: "a1".to_string(),
336                agent_type: "test".to_string(),
337                constitution_id: "default".to_string(),
338                capability_manifest_hash: "abc".to_string(),
339                orchestrator_run_id: None,
340            },
341            summary: Summary {
342                what_changed: "test".to_string(),
343                why: "test".to_string(),
344                impact: "none".to_string(),
345                rollback_plan: "revert".to_string(),
346                open_questions: vec![],
347                alternatives_considered: vec![],
348            },
349            plan: Plan {
350                completed_steps: vec![],
351                next_steps: vec![],
352                decision_log: vec![],
353            },
354            changes: Changes {
355                artifacts: vec![Artifact {
356                    resource_uri: "fs://workspace/src/main.rs".to_string(),
357                    change_type: ChangeType::Modify,
358                    disposition: ArtifactDisposition::Discuss,
359                    diff_ref: String::new(),
360                    rationale: Some("test rationale".to_string()),
361                    explanation_tiers: None,
362                    comments: None,
363                    amendment: None,
364                    tests_run: vec![],
365                    dependencies: vec![],
366                    kind: None,
367                }],
368                patch_sets: vec![],
369                pending_actions: vec![],
370            },
371            risk: Risk {
372                risk_score: 0,
373                findings: vec![],
374                policy_decisions: vec![],
375            },
376            provenance: Provenance {
377                inputs: vec![],
378                tool_trace_hash: "hash".to_string(),
379            },
380            review_requests: ReviewRequests {
381                requested_actions: vec![],
382                reviewers: vec![],
383                required_approvals: 1,
384                notes_to_reviewer: None,
385            },
386            signatures: Signatures {
387                package_hash: "hash".to_string(),
388                agent_signature: "sig".to_string(),
389                gateway_attestation: None,
390            },
391            status: DraftStatus::Draft,
392            verification_warnings: vec![],
393            validation_log: vec![],
394            display_id: None,
395            tag: None,
396            vcs_status: None,
397            parent_draft_id: None,
398            pending_approvals: vec![],
399            supervisor_review: None,
400            ignored_artifacts: vec![],
401            baseline_artifacts: vec![],
402            agent_decision_log: vec![],
403            goal_shortref: None,
404            draft_seq: 0,
405            plan_phase: None,
406        };
407        pkg.status = DraftStatus::PendingReview;
408
409        let adapter = HtmlAdapter::new();
410        let ctx = RenderContext {
411            package: &pkg,
412            detail_level: DetailLevel::Top,
413            file_filters: vec![],
414            diff_provider: None,
415            section_filter: None,
416        };
417        let html = adapter.render(&ctx).unwrap();
418        assert!(html.contains(r#"class="status discuss""#));
419    }
420
421    #[test]
422    fn html_contains_details_for_collapsible_files() {
423        use crate::draft_package::*;
424        use crate::output_adapters::RenderContext;
425        use chrono::Utc;
426        use uuid::Uuid;
427
428        let pkg = DraftPackage {
429            package_version: "1.0.0".to_string(),
430            package_id: Uuid::nil(),
431            created_at: Utc::now(),
432            goal: Goal {
433                goal_id: "g1".to_string(),
434                title: "Test".to_string(),
435                objective: "Test".to_string(),
436                success_criteria: vec![],
437                constraints: vec![],
438                parent_goal_title: None,
439            },
440            iteration: Iteration {
441                iteration_id: "i1".to_string(),
442                sequence: 1,
443                workspace_ref: WorkspaceRef {
444                    ref_type: "staging_dir".to_string(),
445                    ref_name: "staging/g1/1".to_string(),
446                    base_ref: None,
447                },
448            },
449            agent_identity: AgentIdentity {
450                agent_id: "a1".to_string(),
451                agent_type: "test".to_string(),
452                constitution_id: "default".to_string(),
453                capability_manifest_hash: "abc".to_string(),
454                orchestrator_run_id: None,
455            },
456            summary: Summary {
457                what_changed: "test".to_string(),
458                why: "test".to_string(),
459                impact: "none".to_string(),
460                rollback_plan: "revert".to_string(),
461                open_questions: vec![],
462                alternatives_considered: vec![],
463            },
464            plan: Plan {
465                completed_steps: vec![],
466                next_steps: vec![],
467                decision_log: vec![],
468            },
469            changes: Changes {
470                artifacts: vec![Artifact {
471                    resource_uri: "fs://workspace/src/main.rs".to_string(),
472                    change_type: ChangeType::Modify,
473                    disposition: ArtifactDisposition::Pending,
474                    diff_ref: String::new(),
475                    rationale: Some("updated".to_string()),
476                    explanation_tiers: None,
477                    comments: None,
478                    amendment: None,
479                    tests_run: vec![],
480                    dependencies: vec![],
481                    kind: None,
482                }],
483                patch_sets: vec![],
484                pending_actions: vec![],
485            },
486            risk: Risk {
487                risk_score: 0,
488                findings: vec![],
489                policy_decisions: vec![],
490            },
491            provenance: Provenance {
492                inputs: vec![],
493                tool_trace_hash: "hash".to_string(),
494            },
495            review_requests: ReviewRequests {
496                requested_actions: vec![],
497                reviewers: vec![],
498                required_approvals: 1,
499                notes_to_reviewer: None,
500            },
501            signatures: Signatures {
502                package_hash: "hash".to_string(),
503                agent_signature: "sig".to_string(),
504                gateway_attestation: None,
505            },
506            status: DraftStatus::Draft,
507            verification_warnings: vec![],
508            validation_log: vec![],
509            display_id: None,
510            tag: None,
511            vcs_status: None,
512            parent_draft_id: None,
513            pending_approvals: vec![],
514            supervisor_review: None,
515            ignored_artifacts: vec![],
516            baseline_artifacts: vec![],
517            agent_decision_log: vec![],
518            goal_shortref: None,
519            draft_seq: 0,
520            plan_phase: None,
521        };
522
523        let adapter = HtmlAdapter::new();
524        let ctx = RenderContext {
525            package: &pkg,
526            detail_level: DetailLevel::Top,
527            file_filters: vec![],
528            diff_provider: None,
529            section_filter: None,
530        };
531        let html = adapter.render(&ctx).unwrap();
532        // Files wrapped in collapsible <details>
533        assert!(
534            html.contains("<details"),
535            "HTML must contain <details> elements"
536        );
537        // localStorage script present
538        assert!(
539            html.contains("localStorage"),
540            "HTML must contain localStorage persistence script"
541        );
542    }
543
544    #[test]
545    fn html_agent_decision_log_renders_details() {
546        use crate::draft_package::*;
547        use crate::output_adapters::RenderContext;
548        use chrono::Utc;
549        use uuid::Uuid;
550
551        let mut pkg = DraftPackage {
552            package_version: "1.0.0".to_string(),
553            package_id: Uuid::nil(),
554            created_at: Utc::now(),
555            goal: Goal {
556                goal_id: "g1".to_string(),
557                title: "Test".to_string(),
558                objective: "Test".to_string(),
559                success_criteria: vec![],
560                constraints: vec![],
561                parent_goal_title: None,
562            },
563            iteration: Iteration {
564                iteration_id: "i1".to_string(),
565                sequence: 1,
566                workspace_ref: WorkspaceRef {
567                    ref_type: "staging_dir".to_string(),
568                    ref_name: "staging/g1/1".to_string(),
569                    base_ref: None,
570                },
571            },
572            agent_identity: AgentIdentity {
573                agent_id: "a1".to_string(),
574                agent_type: "test".to_string(),
575                constitution_id: "default".to_string(),
576                capability_manifest_hash: "abc".to_string(),
577                orchestrator_run_id: None,
578            },
579            summary: Summary {
580                what_changed: "test".to_string(),
581                why: "test".to_string(),
582                impact: "none".to_string(),
583                rollback_plan: "revert".to_string(),
584                open_questions: vec![],
585                alternatives_considered: vec![],
586            },
587            plan: Plan {
588                completed_steps: vec![],
589                next_steps: vec![],
590                decision_log: vec![],
591            },
592            changes: Changes {
593                artifacts: vec![],
594                patch_sets: vec![],
595                pending_actions: vec![],
596            },
597            risk: Risk {
598                risk_score: 0,
599                findings: vec![],
600                policy_decisions: vec![],
601            },
602            provenance: Provenance {
603                inputs: vec![],
604                tool_trace_hash: "hash".to_string(),
605            },
606            review_requests: ReviewRequests {
607                requested_actions: vec![],
608                reviewers: vec![],
609                required_approvals: 1,
610                notes_to_reviewer: None,
611            },
612            signatures: Signatures {
613                package_hash: "hash".to_string(),
614                agent_signature: "sig".to_string(),
615                gateway_attestation: None,
616            },
617            status: DraftStatus::Draft,
618            verification_warnings: vec![],
619            validation_log: vec![],
620            display_id: None,
621            tag: None,
622            vcs_status: None,
623            parent_draft_id: None,
624            pending_approvals: vec![],
625            supervisor_review: None,
626            ignored_artifacts: vec![],
627            baseline_artifacts: vec![],
628            agent_decision_log: vec![],
629            goal_shortref: None,
630            draft_seq: 0,
631            plan_phase: None,
632        };
633        pkg.agent_decision_log = vec![DecisionLogEntry {
634            decision: "Used Ed25519 over RSA".to_string(),
635            rationale: "Smaller, faster".to_string(),
636            alternatives: vec!["RSA-2048".to_string()],
637            alternatives_considered: vec![],
638            confidence: Some(0.85),
639            context: None,
640        }];
641
642        let adapter = HtmlAdapter::new();
643        let ctx = RenderContext {
644            package: &pkg,
645            detail_level: DetailLevel::Top,
646            file_filters: vec![],
647            diff_provider: None,
648            section_filter: None,
649        };
650        let html = adapter.render(&ctx).unwrap();
651        // Decision log section present with details/summary
652        assert!(
653            html.contains("Agent Decision Log"),
654            "Must contain decision log header"
655        );
656        assert!(
657            html.contains("Used Ed25519 over RSA"),
658            "Must contain decision text"
659        );
660        assert!(html.contains("RSA-2048"), "Must contain alternatives");
661        assert!(html.contains("85%"), "Must show confidence percentage");
662        assert!(
663            html.contains("<details"),
664            "Must use collapsible details elements"
665        );
666    }
667}