1use 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">></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 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 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 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 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 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 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 assert!(
534 html.contains("<details"),
535 "HTML must contain <details> elements"
536 );
537 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 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}