1#![doc = include_str!("../README.md")]
2
3mod source;
4
5use std::collections::{HashMap, HashSet};
6use std::fmt::Write;
7
8use toolpath::v1::{ArtifactChange, Graph, Path, PathOrRef, Step, query};
9
10#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
12pub enum Detail {
13 #[default]
15 Summary,
16 Full,
18}
19
20pub struct RenderOptions {
22 pub detail: Detail,
24 pub front_matter: bool,
26}
27
28impl Default for RenderOptions {
29 fn default() -> Self {
30 Self {
31 detail: Detail::Summary,
32 front_matter: false,
33 }
34 }
35}
36
37pub fn render(graph: &Graph, options: &RenderOptions) -> String {
64 if graph.paths.len() == 1
65 && let PathOrRef::Path(p) = &graph.paths[0]
66 {
67 return render_path(p, options);
68 }
69 render_graph(graph, options)
70}
71
72pub fn render_step(step: &Step, options: &RenderOptions) -> String {
74 let mut out = String::new();
75
76 if options.front_matter {
77 write_step_front_matter(&mut out, step);
78 }
79
80 writeln!(out, "# {}", step.step.id).unwrap();
81 writeln!(out).unwrap();
82 write_step_body(&mut out, step, options, false);
83
84 out
85}
86
87pub fn render_path(path: &Path, options: &RenderOptions) -> String {
89 if is_agent_coding_session(path) {
90 return render_conversation_transcript(path, options);
91 }
92
93 let mut out = String::new();
94
95 if options.front_matter {
96 write_path_front_matter(&mut out, path);
97 }
98
99 let title = path
101 .meta
102 .as_ref()
103 .and_then(|m| m.title.as_deref())
104 .unwrap_or(&path.path.id);
105 writeln!(out, "# {title}").unwrap();
106 writeln!(out).unwrap();
107
108 write_path_context(&mut out, path);
110
111 let sorted = topo_sort(&path.steps);
113 let active = query::ancestors(&path.steps, &path.path.head);
114 let dead_end_set: HashSet<&str> = path
115 .steps
116 .iter()
117 .filter(|s| !active.contains(&s.step.id))
118 .map(|s| s.step.id.as_str())
119 .collect();
120
121 writeln!(out, "## Timeline").unwrap();
123 writeln!(out).unwrap();
124
125 for step in &sorted {
126 let is_dead = dead_end_set.contains(step.step.id.as_str());
127 let is_head = step.step.id == path.path.head;
128 write_path_step(&mut out, step, options, is_dead, is_head);
129 }
130
131 if !dead_end_set.is_empty() {
133 write_dead_ends_section(&mut out, &sorted, &dead_end_set);
134 }
135
136 write_review_section(&mut out, &sorted);
138
139 if let Some(meta) = &path.meta
141 && let Some(actors) = &meta.actors
142 {
143 write_actors_section(&mut out, actors);
144 }
145
146 out
147}
148
149pub fn render_graph(graph: &Graph, options: &RenderOptions) -> String {
151 let mut out = String::new();
152
153 if options.front_matter {
154 write_graph_front_matter(&mut out, graph);
155 }
156
157 let title = graph
159 .meta
160 .as_ref()
161 .and_then(|m| m.title.as_deref())
162 .unwrap_or(&graph.graph.id);
163 writeln!(out, "# {title}").unwrap();
164 writeln!(out).unwrap();
165
166 if let Some(meta) = &graph.meta
168 && let Some(intent) = &meta.intent
169 {
170 writeln!(out, "> {intent}").unwrap();
171 writeln!(out).unwrap();
172 }
173
174 if let Some(meta) = &graph.meta
176 && !meta.refs.is_empty()
177 {
178 for r in &meta.refs {
179 writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap();
180 }
181 writeln!(out).unwrap();
182 }
183
184 let inline_paths: Vec<&Path> = graph
186 .paths
187 .iter()
188 .filter_map(|por| match por {
189 PathOrRef::Path(p) => Some(p.as_ref()),
190 PathOrRef::Ref(_) => None,
191 })
192 .collect();
193
194 let ref_urls: Vec<&str> = graph
195 .paths
196 .iter()
197 .filter_map(|por| match por {
198 PathOrRef::Ref(r) => Some(r.ref_url.as_str()),
199 PathOrRef::Path(_) => None,
200 })
201 .collect();
202
203 if !inline_paths.is_empty() {
204 writeln!(out, "| Path | Steps | Actors | Head |").unwrap();
205 writeln!(out, "|------|-------|--------|------|").unwrap();
206 for path in &inline_paths {
207 let path_title = path
208 .meta
209 .as_ref()
210 .and_then(|m| m.title.as_deref())
211 .unwrap_or(&path.path.id);
212 let step_count = path.steps.len();
213 let actors = query::all_actors(&path.steps);
214 let actors_str = format_actor_list(&actors);
215 writeln!(
216 out,
217 "| {path_title} | {step_count} | {actors_str} | `{}` |",
218 path.path.head
219 )
220 .unwrap();
221 }
222 writeln!(out).unwrap();
223 }
224
225 if !ref_urls.is_empty() {
226 writeln!(out, "**External references:**").unwrap();
227 for url in &ref_urls {
228 writeln!(out, "- `{url}`").unwrap();
229 }
230 writeln!(out).unwrap();
231 }
232
233 for path in &inline_paths {
235 let path_title = path
236 .meta
237 .as_ref()
238 .and_then(|m| m.title.as_deref())
239 .unwrap_or(&path.path.id);
240 writeln!(out, "---").unwrap();
241 writeln!(out).unwrap();
242 writeln!(out, "## {path_title}").unwrap();
243 writeln!(out).unwrap();
244
245 if is_agent_coding_session(path) {
246 write_path_context(&mut out, path);
247 write_conversation_transcript_body(&mut out, path, options);
248 continue;
249 }
250
251 write_path_context(&mut out, path);
252
253 let sorted = topo_sort(&path.steps);
254 let active = query::ancestors(&path.steps, &path.path.head);
255 let dead_end_set: HashSet<&str> = path
256 .steps
257 .iter()
258 .filter(|s| !active.contains(&s.step.id))
259 .map(|s| s.step.id.as_str())
260 .collect();
261
262 for step in &sorted {
263 let is_dead = dead_end_set.contains(step.step.id.as_str());
264 let is_head = step.step.id == path.path.head;
265 write_path_step(&mut out, step, options, is_dead, is_head);
266 }
267
268 if !dead_end_set.is_empty() {
269 write_dead_ends_section(&mut out, &sorted, &dead_end_set);
270 }
271 }
272
273 if let Some(meta) = &graph.meta
275 && let Some(actors) = &meta.actors
276 {
277 writeln!(out, "---").unwrap();
278 writeln!(out).unwrap();
279 write_actors_section(&mut out, actors);
280 }
281
282 out
283}
284
285fn write_step_body(out: &mut String, step: &Step, options: &RenderOptions, compact: bool) {
290 let heading = if compact { "###" } else { "##" };
291
292 writeln!(out, "**Actor:** `{}`", step.step.actor).unwrap();
294 writeln!(out, "**Timestamp:** {}", step.step.timestamp).unwrap();
295
296 if !step.step.parents.is_empty() {
298 let parents: Vec<String> = step.step.parents.iter().map(|p| format!("`{p}`")).collect();
299 writeln!(out, "**Parents:** {}", parents.join(", ")).unwrap();
300 }
301
302 writeln!(out).unwrap();
303
304 if let Some(meta) = &step.meta
306 && let Some(intent) = &meta.intent
307 {
308 writeln!(out, "> {intent}").unwrap();
309 writeln!(out).unwrap();
310 }
311
312 if let Some(meta) = &step.meta
314 && !meta.refs.is_empty()
315 {
316 for r in &meta.refs {
317 writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap();
318 }
319 writeln!(out).unwrap();
320 }
321
322 if !step.change.is_empty() {
324 writeln!(out, "{heading} Changes").unwrap();
325 writeln!(out).unwrap();
326
327 let mut artifacts: Vec<&String> = step.change.keys().collect();
328 artifacts.sort();
329
330 for artifact in artifacts {
331 let change = &step.change[artifact];
332 write_artifact_change(out, artifact, change, options);
333 }
334 }
335}
336
337fn write_artifact_change(
338 out: &mut String,
339 artifact: &str,
340 change: &ArtifactChange,
341 options: &RenderOptions,
342) {
343 let change_type = change
344 .structural
345 .as_ref()
346 .map(|s| s.change_type.as_str())
347 .unwrap_or("");
348
349 match options.detail {
350 Detail::Summary => match change_type {
351 "review.comment" | "review.conversation" => {
352 let display = friendly_artifact_name(artifact);
353 let body = change
354 .structural
355 .as_ref()
356 .and_then(|s| s.extra.get("body"))
357 .and_then(|v| v.as_str())
358 .unwrap_or("");
359 let truncated = truncate_str(body, 120);
360 if truncated.is_empty() {
361 writeln!(out, "- `{display}` (comment)").unwrap();
362 } else {
363 writeln!(out, "- `{display}` \u{2014} \"{truncated}\"").unwrap();
364 }
365 }
366 "review.decision" => {
367 let state = change
368 .structural
369 .as_ref()
370 .and_then(|s| s.extra.get("state"))
371 .and_then(|v| v.as_str())
372 .unwrap_or("COMMENTED");
373 let marker = review_state_marker(state);
374 let body = change.raw.as_deref().unwrap_or("");
375 let truncated = truncate_str(body, 120);
376 if truncated.is_empty() {
377 writeln!(out, "- {marker} {state}").unwrap();
378 } else {
379 writeln!(out, "- {marker} {state} \u{2014} \"{truncated}\"").unwrap();
380 }
381 }
382 "ci.run" => {
383 let name = friendly_artifact_name(artifact);
384 let conclusion = change
385 .structural
386 .as_ref()
387 .and_then(|s| s.extra.get("conclusion"))
388 .and_then(|v| v.as_str())
389 .unwrap_or("unknown");
390 let marker = ci_conclusion_marker(conclusion);
391 writeln!(out, "- {name} {marker} {conclusion}").unwrap();
392 }
393 _ => {
394 let display = friendly_artifact_name(artifact);
395 let annotation = change_annotation(change);
396 writeln!(out, "- `{display}`{annotation}").unwrap();
397 }
398 },
399 Detail::Full => {
400 match change_type {
401 "review.comment" | "review.conversation" => {
402 let display = friendly_artifact_name(artifact);
403 writeln!(out, "**`{display}`**").unwrap();
404 let body = change
405 .structural
406 .as_ref()
407 .and_then(|s| s.extra.get("body"))
408 .and_then(|v| v.as_str())
409 .unwrap_or("");
410 if !body.is_empty() {
411 writeln!(out).unwrap();
412 for line in body.lines() {
413 writeln!(out, "> {line}").unwrap();
414 }
415 }
416 if let Some(raw) = &change.raw {
418 writeln!(out).unwrap();
419 writeln!(out, "```diff").unwrap();
420 writeln!(out, "{raw}").unwrap();
421 writeln!(out, "```").unwrap();
422 }
423 writeln!(out).unwrap();
424 }
425 "review.decision" => {
426 let state = change
427 .structural
428 .as_ref()
429 .and_then(|s| s.extra.get("state"))
430 .and_then(|v| v.as_str())
431 .unwrap_or("COMMENTED");
432 let marker = review_state_marker(state);
433 writeln!(out, "**{marker} {state}**").unwrap();
434 if let Some(raw) = &change.raw {
435 writeln!(out).unwrap();
436 for line in raw.lines() {
437 writeln!(out, "> {line}").unwrap();
438 }
439 }
440 writeln!(out).unwrap();
441 }
442 "ci.run" => {
443 let name = friendly_artifact_name(artifact);
444 let conclusion = change
445 .structural
446 .as_ref()
447 .and_then(|s| s.extra.get("conclusion"))
448 .and_then(|v| v.as_str())
449 .unwrap_or("unknown");
450 let marker = ci_conclusion_marker(conclusion);
451 write!(out, "**{name}** {marker} {conclusion}").unwrap();
452 if let Some(url) = change
453 .structural
454 .as_ref()
455 .and_then(|s| s.extra.get("url"))
456 .and_then(|v| v.as_str())
457 {
458 write!(out, " ([details]({url}))").unwrap();
459 }
460 writeln!(out).unwrap();
461 writeln!(out).unwrap();
462 }
463 _ => {
464 let display = friendly_artifact_name(artifact);
465 writeln!(out, "**`{display}`**").unwrap();
466 if let Some(raw) = &change.raw {
467 writeln!(out).unwrap();
468 writeln!(out, "```diff").unwrap();
469 writeln!(out, "{raw}").unwrap();
470 writeln!(out, "```").unwrap();
471 }
472 if let Some(structural) = &change.structural {
473 writeln!(out).unwrap();
474 let extra_str = if structural.extra.is_empty() {
475 String::new()
476 } else {
477 let pairs: Vec<String> = structural
478 .extra
479 .iter()
480 .map(|(k, v)| format!("{k}={v}"))
481 .collect();
482 format!(" ({})", pairs.join(", "))
483 };
484 writeln!(out, "Structural: `{}`{extra_str}", structural.change_type)
485 .unwrap();
486 }
487 writeln!(out).unwrap();
488 }
489 }
490 }
491 }
492}
493
494fn is_agent_coding_session(path: &Path) -> bool {
497 path.meta.as_ref().and_then(|m| m.kind.as_deref())
498 == Some(toolpath::v1::PATH_KIND_AGENT_CODING_SESSION)
499}
500
501fn render_conversation_transcript(path: &Path, options: &RenderOptions) -> String {
505 let mut out = String::new();
506
507 if options.front_matter {
508 write_path_front_matter(&mut out, path);
509 }
510
511 let title = path
512 .meta
513 .as_ref()
514 .and_then(|m| m.title.as_deref())
515 .unwrap_or(&path.path.id);
516 writeln!(out, "# {title}").unwrap();
517 writeln!(out).unwrap();
518
519 write_transcript_context(&mut out, path);
520 write_conversation_transcript_body(&mut out, path, options);
521 out
522}
523
524fn write_transcript_context(out: &mut String, path: &Path) {
527 if let Some(src) = path.meta.as_ref().and_then(|m| m.source.as_deref()) {
528 writeln!(out, "**Source:** `{src}`").unwrap();
529 }
530 if let Some(base) = &path.path.base {
531 let branch = base
532 .branch
533 .as_deref()
534 .map(|b| format!(" (`{b}`)"))
535 .unwrap_or_default();
536 writeln!(out, "**Base:** `{}`{branch}", base.uri).unwrap();
537 }
538 writeln!(out).unwrap();
539}
540
541fn write_conversation_transcript_body(out: &mut String, path: &Path, options: &RenderOptions) {
545 let active = query::ancestors(&path.steps, &path.path.head);
546 let sorted = topo_sort(&path.steps);
547
548 let mut turns: Vec<&Step> = Vec::new();
549 let mut omitted = 0usize;
550 for &step in &sorted {
551 let is_turn = step.change.values().any(|c| {
552 c.structural.as_ref().map(|s| s.change_type.as_str()) == Some("conversation.append")
553 });
554 if !is_turn {
555 continue; }
557 if !active.contains(step.step.id.as_str()) {
558 omitted += 1;
559 continue;
560 }
561 turns.push(step);
562 }
563
564 if options.detail == Detail::Summary {
565 write_compact_transcript(out, &turns);
566 } else {
567 for step in &turns {
568 let append = step
569 .change
570 .values()
571 .find(|c| {
572 c.structural.as_ref().map(|s| s.change_type.as_str())
573 == Some("conversation.append")
574 })
575 .expect("turn step has a conversation.append change");
576 let mut files: Vec<(&String, &ArtifactChange)> = step
577 .change
578 .iter()
579 .filter(|(_, c)| {
580 c.structural.as_ref().map(|s| s.change_type.as_str()) == Some("file.write")
581 })
582 .collect();
583 files.sort_by(|a, b| a.0.cmp(b.0));
584 write_transcript_turn(out, append, &files, options);
585 }
586 }
587
588 if omitted > 0 {
589 writeln!(
590 out,
591 "_{omitted} abandoned turn{} omitted._",
592 if omitted == 1 { "" } else { "s" }
593 )
594 .unwrap();
595 writeln!(out).unwrap();
596 }
597}
598
599fn write_compact_transcript(out: &mut String, turns: &[&Step]) {
603 let mut pending: Vec<(String, usize)> = Vec::new();
606
607 for step in turns {
608 let Some(append) = step.change.values().find(|c| {
609 c.structural.as_ref().map(|s| s.change_type.as_str()) == Some("conversation.append")
610 }) else {
611 continue;
612 };
613 let Some(s) = append.structural.as_ref() else {
614 continue;
615 };
616 let extra = &s.extra;
617 let text = extra
618 .get("text")
619 .and_then(|v| v.as_str())
620 .unwrap_or("")
621 .trim();
622 let tools = extra.get("tool_uses").and_then(|v| v.as_array());
623
624 if text.is_empty() {
625 accumulate_tools(&mut pending, tools);
626 continue;
627 }
628
629 flush_tool_breakdown(out, &mut pending);
630
631 let role = extra.get("role").and_then(|v| v.as_str()).unwrap_or("");
632 let display = if role == "user" {
633 text.to_string()
634 } else {
635 truncate_str(&text.replace('\n', " "), 200)
636 };
637 writeln!(out, "**{}:** {display}", speaker_label(role)).unwrap();
638 writeln!(out).unwrap();
639
640 accumulate_tools(&mut pending, tools);
641 }
642
643 flush_tool_breakdown(out, &mut pending);
644}
645
646fn accumulate_tools(pending: &mut Vec<(String, usize)>, tools: Option<&Vec<serde_json::Value>>) {
648 let Some(tools) = tools else { return };
649 for tool in tools {
650 let name = tool.get("name").and_then(|v| v.as_str()).unwrap_or("?");
651 match pending.iter_mut().find(|(n, _)| n == name) {
652 Some((_, count)) => *count += 1,
653 None => pending.push((name.to_string(), 1)),
654 }
655 }
656}
657
658fn flush_tool_breakdown(out: &mut String, pending: &mut Vec<(String, usize)>) {
660 if pending.is_empty() {
661 return;
662 }
663 let parts: Vec<String> = pending.iter().map(|(n, c)| format!("{n} ({c})")).collect();
664 writeln!(out, "*tools: {}*", parts.join(", ")).unwrap();
665 writeln!(out).unwrap();
666 pending.clear();
667}
668
669fn write_transcript_turn(
672 out: &mut String,
673 append: &ArtifactChange,
674 files: &[(&String, &ArtifactChange)],
675 options: &RenderOptions,
676) {
677 let Some(s) = append.structural.as_ref() else {
678 return;
679 };
680 let extra = &s.extra;
681 let str_field = |k: &str| extra.get(k).and_then(|v| v.as_str()).unwrap_or("");
682 let text = str_field("text").trim();
683 let thinking = str_field("thinking").trim();
684 let tool_uses = extra
685 .get("tool_uses")
686 .and_then(|v| v.as_array())
687 .filter(|t| !t.is_empty());
688 let delegations = extra
689 .get("delegations")
690 .and_then(|v| v.as_array())
691 .filter(|d| !d.is_empty());
692
693 if text.is_empty()
695 && thinking.is_empty()
696 && tool_uses.is_none()
697 && delegations.is_none()
698 && files.is_empty()
699 {
700 return;
701 }
702
703 let speaker = speaker_label(str_field("role"));
704 if text.is_empty() {
705 writeln!(out, "**{speaker}:**").unwrap();
706 } else {
707 writeln!(out, "**{speaker}:** {text}").unwrap();
708 }
709 writeln!(out).unwrap();
710
711 if !thinking.is_empty() {
712 writeln!(out, "**Reasoning:**").unwrap();
713 writeln!(out).unwrap();
714 for line in thinking.lines() {
715 writeln!(out, "> {line}").unwrap();
716 }
717 writeln!(out).unwrap();
718 }
719 if let Some(tools) = tool_uses {
720 writeln!(out, "**Tools:**").unwrap();
721 for tool in tools {
722 write_tool_use(out, tool);
723 }
724 writeln!(out).unwrap();
725 }
726 if let Some(delegs) = delegations {
727 writeln!(out, "**Delegations:**").unwrap();
728 for d in delegs {
729 let agent = d.get("agent_id").and_then(|v| v.as_str()).unwrap_or("?");
730 let prompt = truncate_str(
731 &d.get("prompt")
732 .and_then(|v| v.as_str())
733 .unwrap_or("")
734 .replace('\n', " "),
735 80,
736 );
737 writeln!(out, "- `{agent}` \u{2014} {prompt}").unwrap();
738 }
739 writeln!(out).unwrap();
740 }
741 for (artifact, change) in files {
742 write_conversation_file_write(out, artifact, change, options);
743 }
744 let meta_line = conversation_meta_line(extra);
745 if !meta_line.is_empty() {
746 writeln!(out, "*{meta_line}*").unwrap();
747 writeln!(out).unwrap();
748 }
749}
750
751fn speaker_label(role: &str) -> String {
753 match role {
754 "user" => "User".into(),
755 "assistant" => "Assistant".into(),
756 "system" => "System".into(),
757 "" => "?".into(),
758 other => {
759 let mut chars = other.chars();
760 let first = chars.next().unwrap();
761 first.to_uppercase().collect::<String>() + chars.as_str()
762 }
763 }
764}
765
766fn write_tool_use(out: &mut String, tool: &serde_json::Value) {
768 let name = tool.get("name").and_then(|v| v.as_str()).unwrap_or("?");
769 let input = tool
770 .get("input")
771 .map(compact_json)
772 .filter(|s| !s.is_empty() && s != "{}" && s != "null")
773 .map(|s| format!(" `{}`", truncate_str(&s, 80)))
774 .unwrap_or_default();
775 write!(out, "- `{name}`{input}").unwrap();
776 if let Some(result) = tool.get("result") {
777 let is_error = result
778 .get("is_error")
779 .and_then(|v| v.as_bool())
780 .unwrap_or(false);
781 let content = result.get("content").and_then(|v| v.as_str()).unwrap_or("");
782 let marker = if is_error { "error: " } else { "" };
783 let content = truncate_str(&content.replace('\n', " "), 80);
784 if !content.is_empty() {
785 write!(out, " \u{2192} {marker}{content}").unwrap();
786 } else if is_error {
787 write!(out, " \u{2192} error").unwrap();
788 }
789 }
790 writeln!(out).unwrap();
791}
792
793fn conversation_meta_line(extra: &HashMap<String, serde_json::Value>) -> String {
795 let mut parts: Vec<String> = Vec::new();
796
797 if let Some(stop) = extra.get("stop_reason").and_then(|v| v.as_str()) {
798 parts.push(format!("stop: {stop}"));
799 }
800
801 if let Some(usage) = extra.get("token_usage") {
802 let n = |k: &str| usage.get(k).and_then(|v| v.as_u64());
803 if let (Some(input), Some(output)) = (n("input_tokens"), n("output_tokens")) {
804 let mut t = format!("tokens: {input} in, {output} out");
805 if let Some(cached) = n("cache_read_tokens") {
806 t.push_str(&format!(", {cached} cached"));
807 }
808 parts.push(t);
809 }
810 }
811
812 if let Some(env) = extra.get("environment")
813 && let Some(wd) = env.get("working_dir").and_then(|v| v.as_str())
814 {
815 let branch = env
816 .get("vcs_branch")
817 .and_then(|v| v.as_str())
818 .map(|b| format!(" ({b})"))
819 .unwrap_or_default();
820 parts.push(format!("cwd: {wd}{branch}"));
821 }
822
823 parts.join(" \u{00b7} ")
824}
825
826fn write_conversation_file_write(
828 out: &mut String,
829 artifact: &str,
830 change: &ArtifactChange,
831 options: &RenderOptions,
832) {
833 let display = friendly_artifact_name(artifact);
834 let op = change
835 .structural
836 .as_ref()
837 .and_then(|s| s.extra.get("operation"))
838 .and_then(|v| v.as_str())
839 .map(|o| format!(" ({o})"))
840 .unwrap_or_default();
841
842 if options.detail == Detail::Summary {
843 writeln!(out, "- wrote `{display}`{op}").unwrap();
844 return;
845 }
846
847 writeln!(out, "**wrote `{display}`**{op}").unwrap();
848 if let Some(raw) = &change.raw {
849 writeln!(out).unwrap();
850 writeln!(out, "```diff").unwrap();
851 writeln!(out, "{raw}").unwrap();
852 writeln!(out, "```").unwrap();
853 }
854 writeln!(out).unwrap();
855}
856
857fn compact_json(v: &serde_json::Value) -> String {
859 match v {
860 serde_json::Value::String(s) => s.clone(),
861 other => serde_json::to_string(other).unwrap_or_default(),
862 }
863}
864
865fn change_annotation(change: &ArtifactChange) -> String {
866 let mut parts = Vec::new();
867
868 if let Some(raw) = &change.raw {
869 let (add, del) = count_diff_lines(raw);
870 if add > 0 || del > 0 {
871 parts.push(format!("+{add} -{del}"));
872 }
873 }
874
875 if let Some(structural) = &change.structural {
876 parts.push(structural.change_type.clone());
877 }
878
879 if parts.is_empty() {
880 String::new()
881 } else {
882 format!(" ({})", parts.join(", "))
883 }
884}
885
886fn count_diff_lines(raw: &str) -> (usize, usize) {
887 let mut add = 0;
888 let mut del = 0;
889 for line in raw.lines() {
890 if line.starts_with('+') && !line.starts_with("+++") {
891 add += 1;
892 } else if line.starts_with('-') && !line.starts_with("---") {
893 del += 1;
894 }
895 }
896 (add, del)
897}
898
899fn write_path_step(
900 out: &mut String,
901 step: &Step,
902 options: &RenderOptions,
903 is_dead: bool,
904 is_head: bool,
905) {
906 let actor_short = actor_display(&step.step.actor);
908 let markers = match (is_dead, is_head) {
909 (true, _) => " [dead end]",
910 (_, true) => " [head]",
911 _ => "",
912 };
913
914 writeln!(
915 out,
916 "### {} \u{2014} {}{}",
917 step.step.id, actor_short, markers
918 )
919 .unwrap();
920 writeln!(out).unwrap();
921
922 writeln!(out, "**Timestamp:** {}", step.step.timestamp).unwrap();
923
924 if !step.step.parents.is_empty() {
926 let parents: Vec<String> = step.step.parents.iter().map(|p| format!("`{p}`")).collect();
927 writeln!(out, "**Parents:** {}", parents.join(", ")).unwrap();
928 }
929
930 writeln!(out).unwrap();
931
932 if let Some(meta) = &step.meta
934 && let Some(intent) = &meta.intent
935 {
936 writeln!(out, "> {intent}").unwrap();
937 writeln!(out).unwrap();
938 }
939
940 if let Some(meta) = &step.meta
942 && !meta.refs.is_empty()
943 {
944 for r in &meta.refs {
945 writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap();
946 }
947 writeln!(out).unwrap();
948 }
949
950 if !step.change.is_empty() {
952 let mut artifacts: Vec<&String> = step.change.keys().collect();
953 artifacts.sort();
954
955 for artifact in artifacts {
956 let change = &step.change[artifact];
957 write_artifact_change(out, artifact, change, options);
958 }
959 if options.detail == Detail::Summary {
960 writeln!(out).unwrap();
961 }
962 }
963}
964
965fn write_path_context(out: &mut String, path: &Path) {
966 let ctx = source::detect(path);
967
968 if let Some(identity) = &ctx.identity_line {
969 writeln!(out, "{identity}").unwrap();
970 }
971
972 if let Some(base) = &path.path.base {
973 write!(out, "**Base:** `{}`", base.uri).unwrap();
974 if let Some(ref_str) = &base.ref_str {
975 write!(out, " @ `{ref_str}`").unwrap();
976 }
977 if let Some(branch) = &base.branch {
978 write!(out, " (`{branch}`)").unwrap();
979 }
980 writeln!(out).unwrap();
981 }
982
983 if ctx.identity_line.is_none() {
985 writeln!(out, "**Head:** `{}`", path.path.head).unwrap();
986 }
987
988 if let Some(meta) = &path.meta {
989 if let Some(source) = &meta.source {
990 writeln!(out, "**Source:** `{source}`").unwrap();
991 }
992 if let Some(intent) = &meta.intent {
993 writeln!(out, "**Intent:** {intent}").unwrap();
994 }
995 if !meta.refs.is_empty() {
996 for r in &meta.refs {
997 writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap();
998 }
999 }
1000 }
1001
1002 let (total_add, total_del, file_count) = ctx
1004 .diffstat
1005 .unwrap_or_else(|| count_total_diff_lines(&path.steps));
1006
1007 if total_add > 0 || total_del > 0 {
1008 write!(out, "**Changes:** +{total_add} \u{2212}{total_del}").unwrap();
1009 if let Some(f) = file_count {
1010 write!(out, " across {f} files").unwrap();
1011 }
1012 writeln!(out).unwrap();
1013 }
1014
1015 let artifacts = query::all_artifacts(&path.steps);
1017 let dead_ends = query::dead_ends(&path.steps, &path.path.head);
1018 writeln!(
1019 out,
1020 "**Steps:** {} | **Artifacts:** {} | **Dead ends:** {}",
1021 path.steps.len(),
1022 artifacts.len(),
1023 dead_ends.len()
1024 )
1025 .unwrap();
1026
1027 writeln!(out).unwrap();
1028}
1029
1030fn write_dead_ends_section(out: &mut String, sorted: &[&Step], dead_end_set: &HashSet<&str>) {
1031 writeln!(out, "## Dead Ends").unwrap();
1032 writeln!(out).unwrap();
1033 writeln!(
1034 out,
1035 "These steps were attempted but did not contribute to the final result."
1036 )
1037 .unwrap();
1038 writeln!(out).unwrap();
1039
1040 for step in sorted {
1041 if !dead_end_set.contains(step.step.id.as_str()) {
1042 continue;
1043 }
1044 let intent = step
1045 .meta
1046 .as_ref()
1047 .and_then(|m| m.intent.as_deref())
1048 .unwrap_or("(no intent recorded)");
1049 let parents: Vec<String> = step.step.parents.iter().map(|p| format!("`{p}`")).collect();
1050 let parent_str = if parents.is_empty() {
1051 "root".to_string()
1052 } else {
1053 parents.join(", ")
1054 };
1055 writeln!(
1056 out,
1057 "- **{}** ({}) \u{2014} {} | Parent: {}",
1058 step.step.id, step.step.actor, intent, parent_str
1059 )
1060 .unwrap();
1061 }
1062 writeln!(out).unwrap();
1063}
1064
1065fn write_review_section(out: &mut String, sorted: &[&Step]) {
1066 struct ReviewDecision<'a> {
1068 state: &'a str,
1069 actor: &'a str,
1070 body: Option<&'a str>,
1071 }
1072 struct ReviewComment<'a> {
1073 artifact: String,
1074 actor: &'a str,
1075 body: &'a str,
1076 diff_hunk: Option<&'a str>,
1077 }
1078 struct ConversationComment<'a> {
1079 actor: &'a str,
1080 body: &'a str,
1081 }
1082
1083 let mut decisions: Vec<ReviewDecision<'_>> = Vec::new();
1084 let mut comments: Vec<ReviewComment<'_>> = Vec::new();
1085 let mut conversations: Vec<ConversationComment<'_>> = Vec::new();
1086
1087 for step in sorted {
1088 for (artifact, change) in &step.change {
1089 let change_type = change
1090 .structural
1091 .as_ref()
1092 .map(|s| s.change_type.as_str())
1093 .unwrap_or("");
1094 match change_type {
1095 "review.decision" => {
1096 let state = change
1097 .structural
1098 .as_ref()
1099 .and_then(|s| s.extra.get("state"))
1100 .and_then(|v| v.as_str())
1101 .unwrap_or("COMMENTED");
1102 let body = change.raw.as_deref();
1103 decisions.push(ReviewDecision {
1104 state,
1105 actor: &step.step.actor,
1106 body,
1107 });
1108 }
1109 "review.comment" => {
1110 let body = change
1111 .structural
1112 .as_ref()
1113 .and_then(|s| s.extra.get("body"))
1114 .and_then(|v| v.as_str())
1115 .unwrap_or("");
1116 if !body.is_empty() {
1117 comments.push(ReviewComment {
1118 artifact: friendly_artifact_name(artifact),
1119 actor: &step.step.actor,
1120 body,
1121 diff_hunk: change.raw.as_deref(),
1122 });
1123 }
1124 }
1125 "review.conversation" => {
1126 let body = change
1127 .structural
1128 .as_ref()
1129 .and_then(|s| s.extra.get("body"))
1130 .and_then(|v| v.as_str())
1131 .unwrap_or("");
1132 if !body.is_empty() {
1133 conversations.push(ConversationComment {
1134 actor: &step.step.actor,
1135 body,
1136 });
1137 }
1138 }
1139 _ => {}
1140 }
1141 }
1142 }
1143
1144 if decisions.is_empty() && comments.is_empty() && conversations.is_empty() {
1145 return;
1146 }
1147
1148 writeln!(out, "## Review").unwrap();
1149 writeln!(out).unwrap();
1150
1151 for d in &decisions {
1152 let marker = review_state_marker(d.state);
1153 let actor_short = d.actor.split(':').next_back().unwrap_or(d.actor);
1154 write!(out, "**{} {}** by {actor_short}", marker, d.state).unwrap();
1155 if let Some(body) = d.body
1156 && !body.is_empty()
1157 {
1158 writeln!(out, ":").unwrap();
1159 for line in body.lines() {
1160 writeln!(out, "> {line}").unwrap();
1161 }
1162 } else {
1163 writeln!(out).unwrap();
1164 }
1165 writeln!(out).unwrap();
1166 }
1167
1168 if !conversations.is_empty() {
1169 writeln!(out, "### Discussion").unwrap();
1170 writeln!(out).unwrap();
1171
1172 for c in &conversations {
1173 let actor_short = c.actor.split(':').next_back().unwrap_or(c.actor);
1174 writeln!(out, "**{actor_short}:**").unwrap();
1175 for line in c.body.lines() {
1176 writeln!(out, "> {line}").unwrap();
1177 }
1178 writeln!(out).unwrap();
1179 }
1180 }
1181
1182 if !comments.is_empty() {
1183 writeln!(out, "### Inline comments").unwrap();
1184 writeln!(out).unwrap();
1185
1186 for c in &comments {
1187 let actor_short = c.actor.split(':').next_back().unwrap_or(c.actor);
1188 writeln!(out, "**{}** \u{2014} {actor_short}:", c.artifact).unwrap();
1189 for line in c.body.lines() {
1190 writeln!(out, "> {line}").unwrap();
1191 }
1192 if let Some(hunk) = c.diff_hunk {
1193 writeln!(out).unwrap();
1194 writeln!(out, "```diff").unwrap();
1195 writeln!(out, "{hunk}").unwrap();
1196 writeln!(out, "```").unwrap();
1197 }
1198 writeln!(out).unwrap();
1199 }
1200 }
1201}
1202
1203fn write_actors_section(out: &mut String, actors: &HashMap<String, toolpath::v1::ActorDefinition>) {
1204 writeln!(out, "## Actors").unwrap();
1205 writeln!(out).unwrap();
1206
1207 let mut keys: Vec<&String> = actors.keys().collect();
1208 keys.sort();
1209
1210 for key in keys {
1211 let def = &actors[key];
1212 let name = def.name.as_deref().unwrap_or(key);
1213 write!(out, "- **`{key}`** \u{2014} {name}").unwrap();
1214 if let Some(provider) = &def.provider {
1215 write!(out, " ({provider}").unwrap();
1216 if let Some(model) = &def.model {
1217 write!(out, ", {model}").unwrap();
1218 }
1219 write!(out, ")").unwrap();
1220 }
1221 writeln!(out).unwrap();
1222 }
1223 writeln!(out).unwrap();
1224}
1225
1226fn write_step_front_matter(out: &mut String, step: &Step) {
1231 writeln!(out, "---").unwrap();
1232 writeln!(out, "type: step").unwrap();
1233 writeln!(out, "id: {}", step.step.id).unwrap();
1234 writeln!(out, "actor: {}", step.step.actor).unwrap();
1235 writeln!(out, "timestamp: {}", step.step.timestamp).unwrap();
1236 if !step.step.parents.is_empty() {
1237 let parents: Vec<&str> = step.step.parents.iter().map(|s| s.as_str()).collect();
1238 writeln!(out, "parents: [{}]", parents.join(", ")).unwrap();
1239 }
1240 let mut artifacts: Vec<&str> = step.change.keys().map(|k| k.as_str()).collect();
1241 artifacts.sort();
1242 if !artifacts.is_empty() {
1243 writeln!(out, "artifacts: [{}]", artifacts.join(", ")).unwrap();
1244 }
1245 writeln!(out, "---").unwrap();
1246 writeln!(out).unwrap();
1247}
1248
1249fn write_path_front_matter(out: &mut String, path: &Path) {
1250 writeln!(out, "---").unwrap();
1251 writeln!(out, "type: path").unwrap();
1252 writeln!(out, "id: {}", path.path.id).unwrap();
1253 writeln!(out, "head: {}", path.path.head).unwrap();
1254 if let Some(base) = &path.path.base {
1255 writeln!(out, "base: {}", base.uri).unwrap();
1256 if let Some(ref_str) = &base.ref_str {
1257 writeln!(out, "base_ref: {ref_str}").unwrap();
1258 }
1259 if let Some(branch) = &base.branch {
1260 writeln!(out, "base_branch: {branch}").unwrap();
1261 }
1262 }
1263 writeln!(out, "steps: {}", path.steps.len()).unwrap();
1264 let actors = query::all_actors(&path.steps);
1265 let mut actor_list: Vec<&str> = actors.iter().copied().collect();
1266 actor_list.sort();
1267 writeln!(out, "actors: [{}]", actor_list.join(", ")).unwrap();
1268 let mut artifacts: Vec<&str> = query::all_artifacts(&path.steps).into_iter().collect();
1269 artifacts.sort();
1270 writeln!(out, "artifacts: [{}]", artifacts.join(", ")).unwrap();
1271 let dead_ends = query::dead_ends(&path.steps, &path.path.head);
1272 writeln!(out, "dead_ends: {}", dead_ends.len()).unwrap();
1273 writeln!(out, "---").unwrap();
1274 writeln!(out).unwrap();
1275}
1276
1277fn write_graph_front_matter(out: &mut String, graph: &Graph) {
1278 writeln!(out, "---").unwrap();
1279 writeln!(out, "type: graph").unwrap();
1280 writeln!(out, "id: {}", graph.graph.id).unwrap();
1281 let inline_count = graph
1282 .paths
1283 .iter()
1284 .filter(|p| matches!(p, PathOrRef::Path(_)))
1285 .count();
1286 let ref_count = graph
1287 .paths
1288 .iter()
1289 .filter(|p| matches!(p, PathOrRef::Ref(_)))
1290 .count();
1291 writeln!(out, "paths: {inline_count}").unwrap();
1292 if ref_count > 0 {
1293 writeln!(out, "external_refs: {ref_count}").unwrap();
1294 }
1295 writeln!(out, "---").unwrap();
1296 writeln!(out).unwrap();
1297}
1298
1299fn actor_display(actor: &str) -> &str {
1308 actor
1309}
1310
1311fn friendly_artifact_name(artifact: &str) -> String {
1317 if let Some(rest) = artifact.strip_prefix("review://") {
1318 if let Some(pos) = rest.rfind("#L") {
1319 format!("{}:{}", &rest[..pos], &rest[pos + 2..])
1320 } else {
1321 rest.to_string()
1322 }
1323 } else if let Some(rest) = artifact.strip_prefix("ci://checks/") {
1324 rest.to_string()
1325 } else {
1326 artifact.to_string()
1327 }
1328}
1329
1330fn truncate_str(s: &str, max: usize) -> String {
1332 let s = s.lines().collect::<Vec<_>>().join(" ").trim().to_string();
1333 if s.chars().count() <= max {
1334 s
1335 } else {
1336 let truncated: String = s.chars().take(max).collect();
1337 format!("{truncated}...")
1338 }
1339}
1340
1341fn review_state_marker(state: &str) -> &'static str {
1343 match state {
1344 "APPROVED" => "[approved]",
1345 "CHANGES_REQUESTED" => "[changes requested]",
1346 "COMMENTED" => "[commented]",
1347 "DISMISSED" => "[dismissed]",
1348 _ => "[review]",
1349 }
1350}
1351
1352fn count_total_diff_lines(steps: &[Step]) -> (u64, u64, Option<u64>) {
1354 let mut total_add: u64 = 0;
1355 let mut total_del: u64 = 0;
1356 let mut files: HashSet<&str> = HashSet::new();
1357 for step in steps {
1358 for (artifact, change) in &step.change {
1359 if artifact.starts_with("review://") || artifact.starts_with("ci://") {
1361 continue;
1362 }
1363 if let Some(raw) = &change.raw {
1364 let (a, d) = count_diff_lines(raw);
1365 total_add += a as u64;
1366 total_del += d as u64;
1367 files.insert(artifact.as_str());
1368 }
1369 }
1370 }
1371 let file_count = if files.is_empty() {
1372 None
1373 } else {
1374 Some(files.len() as u64)
1375 };
1376 (total_add, total_del, file_count)
1377}
1378
1379pub(crate) fn friendly_date_range(steps: &[Step]) -> String {
1382 if steps.is_empty() {
1383 return String::new();
1384 }
1385
1386 let mut first: Option<&str> = None;
1387 let mut last: Option<&str> = None;
1388
1389 for step in steps {
1390 let ts = step.step.timestamp.as_str();
1391 if ts.is_empty() || ts.starts_with("1970") {
1392 continue;
1393 }
1394 match first {
1395 None => {
1396 first = Some(ts);
1397 last = Some(ts);
1398 }
1399 Some(f) => {
1400 if ts < f {
1401 first = Some(ts);
1402 }
1403 if ts > last.unwrap_or("") {
1404 last = Some(ts);
1405 }
1406 }
1407 }
1408 }
1409
1410 let Some(first) = first else {
1411 return String::new();
1412 };
1413 let last = last.unwrap_or(first);
1414
1415 let first_date = &first[..first.len().min(10)];
1417 let last_date = &last[..last.len().min(10)];
1418
1419 let Some(first_fmt) = format_date(first_date) else {
1420 return String::new();
1421 };
1422
1423 if first_date == last_date {
1424 return first_fmt;
1425 }
1426
1427 let Some(last_fmt) = format_date(last_date) else {
1428 return first_fmt;
1429 };
1430
1431 let first_parts: Vec<&str> = first_date.split('-').collect();
1433 let last_parts: Vec<&str> = last_date.split('-').collect();
1434
1435 if first_parts.len() == 3 && last_parts.len() == 3 {
1436 if first_parts[0] == last_parts[0] && first_parts[1] == last_parts[1] {
1437 let month = month_abbrev(first_parts[1]);
1439 let day1 = first_parts[2].trim_start_matches('0');
1440 let day2 = last_parts[2].trim_start_matches('0');
1441 return format!("{month} {day1}\u{2013}{day2}, {}", first_parts[0]);
1442 }
1443 if first_parts[0] == last_parts[0] {
1444 let month1 = month_abbrev(first_parts[1]);
1446 let day1 = first_parts[2].trim_start_matches('0');
1447 let month2 = month_abbrev(last_parts[1]);
1448 let day2 = last_parts[2].trim_start_matches('0');
1449 return format!(
1450 "{month1} {day1} \u{2013} {month2} {day2}, {}",
1451 first_parts[0]
1452 );
1453 }
1454 }
1455
1456 format!("{first_fmt} \u{2013} {last_fmt}")
1458}
1459
1460fn format_date(date: &str) -> Option<String> {
1462 let parts: Vec<&str> = date.split('-').collect();
1463 if parts.len() != 3 {
1464 return None;
1465 }
1466 let month = month_abbrev(parts[1]);
1467 let day = parts[2].trim_start_matches('0');
1468 Some(format!("{month} {day}, {}", parts[0]))
1469}
1470
1471fn month_abbrev(month: &str) -> &'static str {
1472 match month {
1473 "01" => "Jan",
1474 "02" => "Feb",
1475 "03" => "Mar",
1476 "04" => "Apr",
1477 "05" => "May",
1478 "06" => "Jun",
1479 "07" => "Jul",
1480 "08" => "Aug",
1481 "09" => "Sep",
1482 "10" => "Oct",
1483 "11" => "Nov",
1484 "12" => "Dec",
1485 _ => "???",
1486 }
1487}
1488
1489fn ci_conclusion_marker(conclusion: &str) -> &'static str {
1491 match conclusion {
1492 "success" => "[pass]",
1493 "failure" => "[fail]",
1494 "cancelled" | "timed_out" => "[cancelled]",
1495 "skipped" => "[skip]",
1496 "neutral" => "[neutral]",
1497 _ => "[unknown]",
1498 }
1499}
1500
1501fn format_actor_list(actors: &HashSet<&str>) -> String {
1503 let mut list: Vec<&str> = actors.iter().copied().collect();
1504 list.sort();
1505 list.iter()
1506 .map(|a| format!("`{a}`"))
1507 .collect::<Vec<_>>()
1508 .join(", ")
1509}
1510
1511fn topo_sort<'a>(steps: &'a [Step]) -> Vec<&'a Step> {
1514 let index: HashMap<&str, &Step> = steps.iter().map(|s| (s.step.id.as_str(), s)).collect();
1515 let ids: Vec<&str> = steps.iter().map(|s| s.step.id.as_str()).collect();
1516 let id_set: HashSet<&str> = ids.iter().copied().collect();
1517
1518 let mut in_degree: HashMap<&str, usize> = HashMap::new();
1520 let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
1521
1522 for &id in &ids {
1523 in_degree.entry(id).or_insert(0);
1524 children.entry(id).or_default();
1525 }
1526
1527 for step in steps {
1528 for parent in &step.step.parents {
1529 if id_set.contains(parent.as_str()) {
1530 *in_degree.entry(step.step.id.as_str()).or_insert(0) += 1;
1531 children
1532 .entry(parent.as_str())
1533 .or_default()
1534 .push(step.step.id.as_str());
1535 }
1536 }
1537 }
1538
1539 let mut queue: Vec<&str> = ids
1541 .iter()
1542 .copied()
1543 .filter(|id| in_degree.get(id).copied().unwrap_or(0) == 0)
1544 .collect();
1545
1546 let mut result: Vec<&'a Step> = Vec::with_capacity(steps.len());
1547
1548 while let Some(id) = queue.first().copied() {
1549 queue.remove(0);
1550 if let Some(step) = index.get(id) {
1551 result.push(step);
1552 }
1553 if let Some(kids) = children.get(id) {
1554 for &child in kids {
1555 let deg = in_degree.get_mut(child).unwrap();
1556 *deg -= 1;
1557 if *deg == 0 {
1558 queue.push(child);
1559 }
1560 }
1561 }
1562 }
1563
1564 let placed: HashSet<&str> = result.iter().map(|s| s.step.id.as_str()).collect();
1566 for step in steps {
1567 if !placed.contains(step.step.id.as_str()) {
1568 result.push(step);
1569 }
1570 }
1571
1572 result
1573}
1574
1575#[cfg(test)]
1576mod tests {
1577 use super::*;
1578 use toolpath::v1::{
1579 Base, Graph, GraphIdentity, GraphMeta, Path, PathIdentity, PathMeta, PathOrRef, PathRef,
1580 Ref, Step, StructuralChange,
1581 };
1582
1583 fn make_step(id: &str, actor: &str, parents: &[&str]) -> Step {
1584 let mut step = Step::new(id, actor, "2026-01-29T10:00:00Z")
1585 .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new");
1586 for p in parents {
1587 step = step.with_parent(*p);
1588 }
1589 step
1590 }
1591
1592 fn make_step_with_intent(id: &str, actor: &str, parents: &[&str], intent: &str) -> Step {
1593 make_step(id, actor, parents).with_intent(intent)
1594 }
1595
1596 #[test]
1599 fn test_render_step_basic() {
1600 let step = make_step("s1", "human:alex", &[]);
1601 let opts = RenderOptions::default();
1602 let md = render_step(&step, &opts);
1603
1604 assert!(md.starts_with("# s1"));
1605 assert!(md.contains("human:alex"));
1606 assert!(md.contains("src/main.rs"));
1607 }
1608
1609 #[test]
1610 fn test_render_step_with_intent() {
1611 let step = make_step_with_intent("s1", "human:alex", &[], "Fix the bug");
1612 let opts = RenderOptions::default();
1613 let md = render_step(&step, &opts);
1614
1615 assert!(md.contains("> Fix the bug"));
1616 }
1617
1618 #[test]
1619 fn test_render_step_with_parents() {
1620 let step = make_step("s2", "agent:claude", &["s1"]);
1621 let opts = RenderOptions::default();
1622 let md = render_step(&step, &opts);
1623
1624 assert!(md.contains("`s1`"));
1625 }
1626
1627 #[test]
1628 fn test_render_step_with_front_matter() {
1629 let step = make_step("s1", "human:alex", &[]);
1630 let opts = RenderOptions {
1631 front_matter: true,
1632 ..Default::default()
1633 };
1634 let md = render_step(&step, &opts);
1635
1636 assert!(md.starts_with("---\n"));
1637 assert!(md.contains("type: step"));
1638 assert!(md.contains("id: s1"));
1639 assert!(md.contains("actor: human:alex"));
1640 }
1641
1642 #[test]
1643 fn test_render_step_full_detail() {
1644 let step = make_step("s1", "human:alex", &[]);
1645 let opts = RenderOptions {
1646 detail: Detail::Full,
1647 ..Default::default()
1648 };
1649 let md = render_step(&step, &opts);
1650
1651 assert!(md.contains("```diff"));
1652 assert!(md.contains("-old"));
1653 assert!(md.contains("+new"));
1654 }
1655
1656 #[test]
1657 fn test_render_step_summary_has_diffstat() {
1658 let step = make_step("s1", "human:alex", &[]);
1659 let opts = RenderOptions::default();
1660 let md = render_step(&step, &opts);
1661
1662 assert!(md.contains("+1 -1"));
1663 }
1664
1665 #[test]
1668 fn test_render_path_basic() {
1669 let s1 = make_step_with_intent("s1", "human:alex", &[], "Start");
1670 let s2 = make_step_with_intent("s2", "agent:claude", &["s1"], "Continue");
1671 let path = Path {
1672 path: PathIdentity {
1673 id: "p1".into(),
1674 base: Some(Base::vcs("github:org/repo", "abc123")),
1675 head: "s2".into(),
1676 graph_ref: None,
1677 },
1678 steps: vec![s1, s2],
1679 meta: Some(PathMeta {
1680 title: Some("My PR".into()),
1681 ..Default::default()
1682 }),
1683 };
1684 let opts = RenderOptions::default();
1685 let md = render_path(&path, &opts);
1686
1687 assert!(md.starts_with("# My PR"));
1688 assert!(md.contains("github:org/repo"));
1689 assert!(md.contains("## Timeline"));
1690 assert!(md.contains("### s1"));
1691 assert!(md.contains("### s2"));
1692 assert!(md.contains("[head]"));
1693 }
1694
1695 #[test]
1696 fn test_render_path_with_dead_ends() {
1697 let s1 = make_step("s1", "human:alex", &[]);
1698 let s2 = make_step_with_intent("s2", "agent:claude", &["s1"], "Good approach");
1699 let s2a = make_step_with_intent("s2a", "agent:claude", &["s1"], "Bad approach (abandoned)");
1700 let s3 = make_step("s3", "human:alex", &["s2"]);
1701 let path = Path {
1702 path: PathIdentity {
1703 id: "p1".into(),
1704 base: None,
1705 head: "s3".into(),
1706 graph_ref: None,
1707 },
1708 steps: vec![s1, s2, s2a, s3],
1709 meta: None,
1710 };
1711 let opts = RenderOptions::default();
1712 let md = render_path(&path, &opts);
1713
1714 assert!(md.contains("[dead end]"));
1715 assert!(md.contains("## Dead Ends"));
1716 assert!(md.contains("Bad approach (abandoned)"));
1717 }
1718
1719 #[test]
1720 fn test_render_path_with_front_matter() {
1721 let s1 = make_step("s1", "human:alex", &[]);
1722 let path = Path {
1723 path: PathIdentity {
1724 id: "p1".into(),
1725 base: None,
1726 head: "s1".into(),
1727 graph_ref: None,
1728 },
1729 steps: vec![s1],
1730 meta: None,
1731 };
1732 let opts = RenderOptions {
1733 front_matter: true,
1734 ..Default::default()
1735 };
1736 let md = render_path(&path, &opts);
1737
1738 assert!(md.starts_with("---\n"));
1739 assert!(md.contains("type: path"));
1740 assert!(md.contains("id: p1"));
1741 assert!(md.contains("steps: 1"));
1742 assert!(md.contains("dead_ends: 0"));
1743 }
1744
1745 #[test]
1746 fn test_render_path_stats_line() {
1747 let s1 = make_step("s1", "human:alex", &[]);
1748 let s2 = make_step("s2", "agent:claude", &["s1"]);
1749 let path = Path {
1750 path: PathIdentity {
1751 id: "p1".into(),
1752 base: None,
1753 head: "s2".into(),
1754 graph_ref: None,
1755 },
1756 steps: vec![s1, s2],
1757 meta: None,
1758 };
1759 let md = render_path(&path, &RenderOptions::default());
1760
1761 assert!(md.contains("**Steps:** 2"));
1762 assert!(md.contains("**Artifacts:** 1"));
1763 assert!(md.contains("**Dead ends:** 0"));
1764 }
1765
1766 #[test]
1767 fn test_render_path_with_refs() {
1768 let s1 = make_step("s1", "human:alex", &[]);
1769 let path = Path {
1770 path: PathIdentity {
1771 id: "p1".into(),
1772 base: None,
1773 head: "s1".into(),
1774 graph_ref: None,
1775 },
1776 steps: vec![s1],
1777 meta: Some(PathMeta {
1778 refs: vec![Ref {
1779 rel: "fixes".into(),
1780 href: "issue://github/org/repo/issues/42".into(),
1781 }],
1782 ..Default::default()
1783 }),
1784 };
1785 let md = render_path(&path, &RenderOptions::default());
1786
1787 assert!(md.contains("**fixes:**"));
1788 assert!(md.contains("issue://github/org/repo/issues/42"));
1789 }
1790
1791 #[test]
1794 fn test_render_graph_basic() {
1795 let s1 = make_step_with_intent("s1", "human:alex", &[], "First");
1796 let s2 = make_step_with_intent("s2", "agent:claude", &["s1"], "Second");
1797 let path1 = Path {
1798 path: PathIdentity {
1799 id: "p1".into(),
1800 base: Some(Base::vcs("github:org/repo", "abc")),
1801 head: "s2".into(),
1802 graph_ref: None,
1803 },
1804 steps: vec![s1, s2],
1805 meta: Some(PathMeta {
1806 title: Some("PR #42".into()),
1807 ..Default::default()
1808 }),
1809 };
1810
1811 let s3 = make_step("s3", "human:bob", &[]);
1812 let path2 = Path {
1813 path: PathIdentity {
1814 id: "p2".into(),
1815 base: None,
1816 head: "s3".into(),
1817 graph_ref: None,
1818 },
1819 steps: vec![s3],
1820 meta: Some(PathMeta {
1821 title: Some("PR #43".into()),
1822 ..Default::default()
1823 }),
1824 };
1825
1826 let graph = Graph {
1827 graph: GraphIdentity { id: "g1".into() },
1828 paths: vec![
1829 PathOrRef::Path(Box::new(path1)),
1830 PathOrRef::Path(Box::new(path2)),
1831 ],
1832 meta: Some(GraphMeta {
1833 title: Some("Release v2.0".into()),
1834 ..Default::default()
1835 }),
1836 };
1837 let opts = RenderOptions::default();
1838 let md = render_graph(&graph, &opts);
1839
1840 assert!(md.starts_with("# Release v2.0"));
1841 assert!(md.contains("| PR #42"));
1842 assert!(md.contains("| PR #43"));
1843 assert!(md.contains("## PR #42"));
1844 assert!(md.contains("## PR #43"));
1845 }
1846
1847 #[test]
1848 fn test_render_graph_with_refs() {
1849 let graph = Graph {
1850 graph: GraphIdentity { id: "g1".into() },
1851 paths: vec![PathOrRef::Ref(PathRef {
1852 ref_url: "https://example.com/path.json".into(),
1853 })],
1854 meta: None,
1855 };
1856 let md = render_graph(&graph, &RenderOptions::default());
1857
1858 assert!(md.contains("External references"));
1859 assert!(md.contains("example.com/path.json"));
1860 }
1861
1862 #[test]
1863 fn test_render_graph_with_front_matter() {
1864 let s1 = make_step("s1", "human:alex", &[]);
1865 let path1 = Path {
1866 path: PathIdentity {
1867 id: "p1".into(),
1868 base: None,
1869 head: "s1".into(),
1870 graph_ref: None,
1871 },
1872 steps: vec![s1],
1873 meta: None,
1874 };
1875 let graph = Graph {
1876 graph: GraphIdentity { id: "g1".into() },
1877 paths: vec![
1878 PathOrRef::Path(Box::new(path1)),
1879 PathOrRef::Ref(PathRef {
1880 ref_url: "https://example.com".into(),
1881 }),
1882 ],
1883 meta: None,
1884 };
1885 let opts = RenderOptions {
1886 front_matter: true,
1887 ..Default::default()
1888 };
1889 let md = render_graph(&graph, &opts);
1890
1891 assert!(md.starts_with("---\n"));
1892 assert!(md.contains("type: graph"));
1893 assert!(md.contains("paths: 1"));
1894 assert!(md.contains("external_refs: 1"));
1895 }
1896
1897 #[test]
1898 fn test_render_graph_with_meta_refs() {
1899 let graph = Graph {
1900 graph: GraphIdentity { id: "g1".into() },
1901 paths: vec![],
1902 meta: Some(GraphMeta {
1903 title: Some("Release".into()),
1904 refs: vec![Ref {
1905 rel: "milestone".into(),
1906 href: "issue://github/org/repo/milestone/5".into(),
1907 }],
1908 ..Default::default()
1909 }),
1910 };
1911 let md = render_graph(&graph, &RenderOptions::default());
1912
1913 assert!(md.contains("**milestone:**"));
1914 }
1915
1916 #[test]
1919 fn test_render_single_path_graph_uses_path_layout() {
1920 let s1 = make_step("s1", "human:alex", &[]);
1921 let path = Path {
1922 path: PathIdentity {
1923 id: "p1".into(),
1924 base: None,
1925 head: "s1".into(),
1926 graph_ref: None,
1927 },
1928 steps: vec![s1],
1929 meta: None,
1930 };
1931 let graph = Graph::from_path(path);
1932 let md = render(&graph, &RenderOptions::default());
1933 assert!(md.contains("## Timeline"));
1934 }
1935
1936 #[test]
1937 fn test_render_empty_graph_uses_graph_layout() {
1938 let graph = Graph {
1939 graph: GraphIdentity { id: "g1".into() },
1940 paths: vec![],
1941 meta: Some(GraphMeta {
1942 title: Some("My Graph".into()),
1943 ..Default::default()
1944 }),
1945 };
1946 let md = render(&graph, &RenderOptions::default());
1947 assert!(md.contains("# My Graph"));
1948 }
1949
1950 #[test]
1953 fn test_topo_sort_linear() {
1954 let s1 = make_step("s1", "human:alex", &[]);
1955 let s2 = make_step("s2", "agent:claude", &["s1"]);
1956 let s3 = make_step("s3", "human:alex", &["s2"]);
1957 let steps = vec![s3.clone(), s1.clone(), s2.clone()]; let sorted = topo_sort(&steps);
1959 let ids: Vec<&str> = sorted.iter().map(|s| s.step.id.as_str()).collect();
1960 assert_eq!(ids, vec!["s1", "s2", "s3"]);
1961 }
1962
1963 #[test]
1964 fn test_topo_sort_branching() {
1965 let s1 = make_step("s1", "human:alex", &[]);
1966 let s2a = make_step("s2a", "agent:claude", &["s1"]);
1967 let s2b = make_step("s2b", "agent:claude", &["s1"]);
1968 let s3 = make_step("s3", "human:alex", &["s2a", "s2b"]);
1969 let steps = vec![s1, s2a, s2b, s3];
1970 let sorted = topo_sort(&steps);
1971 let ids: Vec<&str> = sorted.iter().map(|s| s.step.id.as_str()).collect();
1972
1973 assert_eq!(ids[0], "s1");
1975 assert_eq!(ids[3], "s3");
1976 }
1977
1978 #[test]
1979 fn test_topo_sort_preserves_input_order_for_roots() {
1980 let s1 = make_step("s1", "human:alex", &[]);
1981 let s2 = make_step("s2", "human:bob", &[]);
1982 let steps = vec![s1, s2];
1983 let sorted = topo_sort(&steps);
1984 let ids: Vec<&str> = sorted.iter().map(|s| s.step.id.as_str()).collect();
1985 assert_eq!(ids, vec!["s1", "s2"]);
1986 }
1987
1988 #[test]
1991 fn test_count_diff_lines() {
1992 let diff = "@@ -1,3 +1,4 @@\n-old1\n-old2\n+new1\n+new2\n+new3\n context";
1993 let (add, del) = count_diff_lines(diff);
1994 assert_eq!(add, 3);
1995 assert_eq!(del, 2);
1996 }
1997
1998 #[test]
1999 fn test_count_diff_lines_ignores_triple_prefix() {
2000 let diff = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new";
2001 let (add, del) = count_diff_lines(diff);
2002 assert_eq!(add, 1);
2003 assert_eq!(del, 1);
2004 }
2005
2006 #[test]
2007 fn test_count_diff_lines_empty() {
2008 assert_eq!(count_diff_lines(""), (0, 0));
2009 }
2010
2011 #[test]
2014 fn test_render_structural_change_summary() {
2015 let mut step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z");
2016 step.change.insert(
2017 "src/main.rs".into(),
2018 toolpath::v1::ArtifactChange {
2019 raw: None,
2020 structural: Some(StructuralChange {
2021 change_type: "rename_function".into(),
2022 extra: Default::default(),
2023 }),
2024 },
2025 );
2026 let md = render_step(&step, &RenderOptions::default());
2027 assert!(md.contains("rename_function"));
2028 }
2029
2030 #[test]
2031 fn test_render_structural_change_full() {
2032 let mut extra = std::collections::HashMap::new();
2033 extra.insert("from".to_string(), serde_json::json!("foo"));
2034 extra.insert("to".to_string(), serde_json::json!("bar"));
2035 let mut step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z");
2036 step.change.insert(
2037 "src/main.rs".into(),
2038 toolpath::v1::ArtifactChange {
2039 raw: None,
2040 structural: Some(StructuralChange {
2041 change_type: "rename_function".into(),
2042 extra,
2043 }),
2044 },
2045 );
2046 let md = render_step(
2047 &step,
2048 &RenderOptions {
2049 detail: Detail::Full,
2050 ..Default::default()
2051 },
2052 );
2053 assert!(md.contains("Structural: `rename_function`"));
2054 }
2055
2056 #[test]
2059 fn test_render_path_with_actors() {
2060 let s1 = make_step("s1", "human:alex", &[]);
2061 let mut actors = std::collections::HashMap::new();
2062 actors.insert(
2063 "human:alex".into(),
2064 toolpath::v1::ActorDefinition {
2065 name: Some("Alex".into()),
2066 provider: None,
2067 model: None,
2068 identities: vec![],
2069 keys: vec![],
2070 },
2071 );
2072 actors.insert(
2073 "agent:claude-code".into(),
2074 toolpath::v1::ActorDefinition {
2075 name: Some("Claude Code".into()),
2076 provider: Some("Anthropic".into()),
2077 model: Some("claude-sonnet-4-20250514".into()),
2078 identities: vec![],
2079 keys: vec![],
2080 },
2081 );
2082 let path = Path {
2083 path: PathIdentity {
2084 id: "p1".into(),
2085 base: None,
2086 head: "s1".into(),
2087 graph_ref: None,
2088 },
2089 steps: vec![s1],
2090 meta: Some(PathMeta {
2091 actors: Some(actors),
2092 ..Default::default()
2093 }),
2094 };
2095 let md = render_path(&path, &RenderOptions::default());
2096
2097 assert!(md.contains("## Actors"));
2098 assert!(md.contains("Alex"));
2099 assert!(md.contains("Claude Code"));
2100 assert!(md.contains("Anthropic"));
2101 }
2102
2103 #[test]
2106 fn test_render_path_full_detail() {
2107 let s1 = make_step("s1", "human:alex", &[]);
2108 let path = Path {
2109 path: PathIdentity {
2110 id: "p1".into(),
2111 base: None,
2112 head: "s1".into(),
2113 graph_ref: None,
2114 },
2115 steps: vec![s1],
2116 meta: None,
2117 };
2118 let opts = RenderOptions {
2119 detail: Detail::Full,
2120 ..Default::default()
2121 };
2122 let md = render_path(&path, &opts);
2123
2124 assert!(md.contains("```diff"));
2125 assert!(md.contains("-old"));
2126 assert!(md.contains("+new"));
2127 }
2128
2129 #[test]
2132 fn test_render_path_no_title() {
2133 let s1 = make_step("s1", "human:alex", &[]);
2134 let path = Path {
2135 path: PathIdentity {
2136 id: "path-42".into(),
2137 base: None,
2138 head: "s1".into(),
2139 graph_ref: None,
2140 },
2141 steps: vec![s1],
2142 meta: None,
2143 };
2144 let md = render_path(&path, &RenderOptions::default());
2145 assert!(md.starts_with("# path-42"));
2146 }
2147
2148 #[test]
2149 fn test_render_step_no_changes() {
2150 let step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z");
2151 let md = render_step(&step, &RenderOptions::default());
2152 assert!(md.contains("# s1"));
2153 assert!(!md.contains("## Changes"));
2154 }
2155
2156 #[test]
2157 fn test_render_graph_empty_paths() {
2158 let graph = Graph {
2159 graph: GraphIdentity { id: "g1".into() },
2160 paths: vec![],
2161 meta: None,
2162 };
2163 let md = render_graph(&graph, &RenderOptions::default());
2164 assert!(md.contains("# g1"));
2165 }
2166
2167 fn make_review_comment_step(id: &str, actor: &str, artifact: &str, body: &str) -> Step {
2170 let mut extra = std::collections::HashMap::new();
2171 extra.insert("body".to_string(), serde_json::json!(body));
2172 let mut step = Step::new(id, actor, "2026-01-29T10:00:00Z");
2173 step.change.insert(
2174 artifact.to_string(),
2175 ArtifactChange {
2176 raw: Some("@@ -1,3 +1,4 @@\n fn example() {\n+ let x = 42;\n }".to_string()),
2177 structural: Some(StructuralChange {
2178 change_type: "review.comment".into(),
2179 extra,
2180 }),
2181 },
2182 );
2183 step
2184 }
2185
2186 fn make_review_decision_step(id: &str, actor: &str, state: &str, body: &str) -> Step {
2187 let mut extra = std::collections::HashMap::new();
2188 extra.insert("state".to_string(), serde_json::json!(state));
2189 let mut step = Step::new(id, actor, "2026-01-29T11:00:00Z");
2190 step.change.insert(
2191 "review://decision".to_string(),
2192 ArtifactChange {
2193 raw: if body.is_empty() {
2194 None
2195 } else {
2196 Some(body.to_string())
2197 },
2198 structural: Some(StructuralChange {
2199 change_type: "review.decision".into(),
2200 extra,
2201 }),
2202 },
2203 );
2204 step
2205 }
2206
2207 fn make_ci_step(id: &str, name: &str, conclusion: &str) -> Step {
2208 let mut extra = std::collections::HashMap::new();
2209 extra.insert("conclusion".to_string(), serde_json::json!(conclusion));
2210 extra.insert(
2211 "url".to_string(),
2212 serde_json::json!("https://github.com/acme/widgets/actions/runs/123"),
2213 );
2214 let mut step = Step::new(id, "ci:github-actions", "2026-01-29T12:00:00Z");
2215 step.change.insert(
2216 format!("ci://checks/{}", name),
2217 ArtifactChange {
2218 raw: None,
2219 structural: Some(StructuralChange {
2220 change_type: "ci.run".into(),
2221 extra,
2222 }),
2223 },
2224 );
2225 step
2226 }
2227
2228 #[test]
2229 fn test_render_review_comment_summary() {
2230 let step = make_review_comment_step(
2231 "s1",
2232 "human:bob",
2233 "review://src/main.rs#L42",
2234 "Consider using a constant here.",
2235 );
2236 let md = render_step(&step, &RenderOptions::default());
2237
2238 assert!(md.contains("src/main.rs:42"));
2240 assert!(md.contains("Consider using a constant here."));
2241 assert!(!md.contains("review://"));
2243 }
2244
2245 #[test]
2246 fn test_render_review_comment_full() {
2247 let step = make_review_comment_step(
2248 "s1",
2249 "human:bob",
2250 "review://src/main.rs#L42",
2251 "Consider using a constant here.",
2252 );
2253 let md = render_step(
2254 &step,
2255 &RenderOptions {
2256 detail: Detail::Full,
2257 ..Default::default()
2258 },
2259 );
2260
2261 assert!(md.contains("> Consider using a constant here."));
2263 assert!(md.contains("```diff"));
2265 assert!(md.contains("let x = 42"));
2266 }
2267
2268 #[test]
2269 fn test_render_review_decision_summary() {
2270 let step = make_review_decision_step("s1", "human:dave", "APPROVED", "LGTM!");
2271 let md = render_step(&step, &RenderOptions::default());
2272
2273 assert!(md.contains("[approved]"));
2274 assert!(md.contains("APPROVED"));
2275 assert!(md.contains("LGTM!"));
2276 }
2277
2278 #[test]
2279 fn test_render_ci_summary() {
2280 let step = make_ci_step("s1", "test", "success");
2281 let md = render_step(&step, &RenderOptions::default());
2282
2283 assert!(md.contains("test"));
2284 assert!(md.contains("[pass]"));
2285 assert!(md.contains("success"));
2286 assert!(!md.contains("ci://checks/"));
2288 }
2289
2290 #[test]
2291 fn test_render_ci_failure() {
2292 let step = make_ci_step("s1", "lint", "failure");
2293 let md = render_step(&step, &RenderOptions::default());
2294
2295 assert!(md.contains("lint"));
2296 assert!(md.contains("[fail]"));
2297 assert!(md.contains("failure"));
2298 }
2299
2300 #[test]
2301 fn test_render_ci_full_with_url() {
2302 let step = make_ci_step("s1", "test", "success");
2303 let md = render_step(
2304 &step,
2305 &RenderOptions {
2306 detail: Detail::Full,
2307 ..Default::default()
2308 },
2309 );
2310
2311 assert!(md.contains("details"));
2312 assert!(md.contains("actions/runs/123"));
2313 }
2314
2315 #[test]
2316 fn test_render_review_section() {
2317 let s1 = make_step("s1", "human:alice", &[]);
2318 let s2 = make_review_comment_step(
2319 "s2",
2320 "human:bob",
2321 "review://src/main.rs#L42",
2322 "Consider using a constant.",
2323 );
2324 let s3 = make_review_decision_step("s3", "human:dave", "APPROVED", "Ship it!");
2325 let mut s2 = s2;
2326 s2 = s2.with_parent("s1");
2327 let mut s3 = s3;
2328 s3 = s3.with_parent("s2");
2329 let path = Path {
2330 path: PathIdentity {
2331 id: "p1".into(),
2332 base: None,
2333 head: "s3".into(),
2334 graph_ref: None,
2335 },
2336 steps: vec![s1, s2, s3],
2337 meta: None,
2338 };
2339 let md = render_path(&path, &RenderOptions::default());
2340
2341 assert!(md.contains("## Review"));
2342 assert!(md.contains("APPROVED"));
2343 assert!(md.contains("Ship it!"));
2344 assert!(md.contains("### Inline comments"));
2345 assert!(md.contains("src/main.rs:42"));
2346 assert!(md.contains("Consider using a constant."));
2347 }
2348
2349 #[test]
2350 fn test_render_no_review_section_without_reviews() {
2351 let s1 = make_step("s1", "human:alex", &[]);
2352 let path = Path {
2353 path: PathIdentity {
2354 id: "p1".into(),
2355 base: None,
2356 head: "s1".into(),
2357 graph_ref: None,
2358 },
2359 steps: vec![s1],
2360 meta: None,
2361 };
2362 let md = render_path(&path, &RenderOptions::default());
2363
2364 assert!(!md.contains("## Review"));
2365 }
2366
2367 #[test]
2370 fn test_render_pr_identity() {
2371 let s1 = make_step("s1", "human:alice", &[]);
2372 let mut extra = std::collections::HashMap::new();
2373 let github = serde_json::json!({
2374 "number": 42,
2375 "author": "alice",
2376 "state": "open",
2377 "draft": false,
2378 "merged": false,
2379 "additions": 150,
2380 "deletions": 30,
2381 "changed_files": 5
2382 });
2383 extra.insert("github".to_string(), github);
2384 let path = Path {
2385 path: PathIdentity {
2386 id: "pr-42".into(),
2387 base: None,
2388 head: "s1".into(),
2389 graph_ref: None,
2390 },
2391 steps: vec![s1],
2392 meta: Some(PathMeta {
2393 title: Some("Add feature".into()),
2394 extra,
2395 ..Default::default()
2396 }),
2397 };
2398 let md = render_path(&path, &RenderOptions::default());
2399
2400 assert!(md.contains("**PR #42**"));
2401 assert!(md.contains("by alice"));
2402 assert!(md.contains("open"));
2403 assert!(md.contains("+150"));
2404 assert!(md.contains("\u{2212}30"));
2405 assert!(md.contains("5 files"));
2406 assert!(!md.contains("**Head:**"));
2408 }
2409
2410 #[test]
2411 fn test_render_no_pr_identity_without_github_meta() {
2412 let s1 = make_step("s1", "human:alex", &[]);
2413 let path = Path {
2414 path: PathIdentity {
2415 id: "p1".into(),
2416 base: None,
2417 head: "s1".into(),
2418 graph_ref: None,
2419 },
2420 steps: vec![s1],
2421 meta: None,
2422 };
2423 let md = render_path(&path, &RenderOptions::default());
2424
2425 assert!(md.contains("**Head:**"));
2427 assert!(!md.contains("**PR #"));
2428 }
2429
2430 #[test]
2433 fn test_friendly_artifact_name() {
2434 assert_eq!(
2435 friendly_artifact_name("review://src/main.rs#L42"),
2436 "src/main.rs:42"
2437 );
2438 assert_eq!(friendly_artifact_name("ci://checks/test"), "test");
2439 assert_eq!(friendly_artifact_name("review://decision"), "decision");
2440 assert_eq!(friendly_artifact_name("src/main.rs"), "src/main.rs");
2441 }
2442
2443 #[test]
2444 fn test_friendly_date_range_same_day() {
2445 let s1 = Step::new("s1", "human:alex", "2026-02-26T10:00:00Z");
2446 let s2 = Step::new("s2", "human:alex", "2026-02-26T14:00:00Z");
2447 assert_eq!(friendly_date_range(&[s1, s2]), "Feb 26, 2026");
2448 }
2449
2450 #[test]
2451 fn test_friendly_date_range_same_month() {
2452 let s1 = Step::new("s1", "human:alex", "2026-02-26T10:00:00Z");
2453 let s2 = Step::new("s2", "human:alex", "2026-02-27T14:00:00Z");
2454 assert_eq!(friendly_date_range(&[s1, s2]), "Feb 26\u{2013}27, 2026");
2455 }
2456
2457 #[test]
2458 fn test_friendly_date_range_different_months() {
2459 let s1 = Step::new("s1", "human:alex", "2026-02-26T10:00:00Z");
2460 let s2 = Step::new("s2", "human:alex", "2026-03-01T14:00:00Z");
2461 assert_eq!(
2462 friendly_date_range(&[s1, s2]),
2463 "Feb 26 \u{2013} Mar 1, 2026"
2464 );
2465 }
2466
2467 #[test]
2468 fn test_friendly_date_range_empty() {
2469 assert_eq!(friendly_date_range(&[]), "");
2470 }
2471
2472 #[test]
2473 fn test_truncate_str() {
2474 assert_eq!(truncate_str("hello", 10), "hello");
2475 assert_eq!(
2476 truncate_str("hello world this is long", 10),
2477 "hello worl..."
2478 );
2479 assert_eq!(truncate_str("line1\nline2", 20), "line1 line2");
2480 }
2481
2482 fn make_conversation_step(id: &str, actor: &str, body: &str) -> Step {
2485 let mut extra = std::collections::HashMap::new();
2486 extra.insert("body".to_string(), serde_json::json!(body));
2487 let mut step = Step::new(id, actor, "2026-01-29T15:00:00Z");
2488 step.change.insert(
2489 "review://conversation".to_string(),
2490 ArtifactChange {
2491 raw: None,
2492 structural: Some(StructuralChange {
2493 change_type: "review.conversation".into(),
2494 extra,
2495 }),
2496 },
2497 );
2498 step
2499 }
2500
2501 #[test]
2502 fn test_render_conversation_summary() {
2503 let step = make_conversation_step("s1", "human:carol", "Looks good overall!");
2504 let md = render_step(&step, &RenderOptions::default());
2505
2506 assert!(md.contains("conversation"));
2507 assert!(md.contains("Looks good overall!"));
2508 assert!(!md.contains("review://"));
2510 }
2511
2512 #[test]
2513 fn test_render_conversation_full() {
2514 let step = make_conversation_step("s1", "human:carol", "Looks good overall!");
2515 let md = render_step(
2516 &step,
2517 &RenderOptions {
2518 detail: Detail::Full,
2519 ..Default::default()
2520 },
2521 );
2522
2523 assert!(md.contains("> Looks good overall!"));
2524 assert!(!md.contains("review://"));
2525 }
2526
2527 #[test]
2528 fn test_review_section_includes_conversations() {
2529 let s1 = make_step("s1", "human:alice", &[]);
2530 let s2 = make_conversation_step("s2", "human:carol", "Looks good overall!");
2531 let s3 = make_review_decision_step("s3", "human:dave", "APPROVED", "Ship it!");
2532 let s2 = s2.with_parent("s1");
2533 let s3 = s3.with_parent("s2");
2534 let path = Path {
2535 path: PathIdentity {
2536 id: "p1".into(),
2537 base: None,
2538 head: "s3".into(),
2539 graph_ref: None,
2540 },
2541 steps: vec![s1, s2, s3],
2542 meta: None,
2543 };
2544 let md = render_path(&path, &RenderOptions::default());
2545
2546 assert!(md.contains("## Review"));
2547 assert!(md.contains("### Discussion"));
2548 assert!(md.contains("carol"));
2549 assert!(md.contains("Looks good overall!"));
2550 assert!(md.contains("APPROVED"));
2551 }
2552
2553 #[test]
2554 fn test_render_merged_pr() {
2555 let s1 = make_step("s1", "human:alice", &[]);
2556 let mut extra = std::collections::HashMap::new();
2557 let github = serde_json::json!({
2558 "number": 7,
2559 "author": "alice",
2560 "state": "closed",
2561 "draft": false,
2562 "merged": true,
2563 "additions": 42,
2564 "deletions": 10,
2565 "changed_files": 3
2566 });
2567 extra.insert("github".to_string(), github);
2568 let path = Path {
2569 path: PathIdentity {
2570 id: "pr-7".into(),
2571 base: None,
2572 head: "s1".into(),
2573 graph_ref: None,
2574 },
2575 steps: vec![s1],
2576 meta: Some(PathMeta {
2577 title: Some("Fix the thing".into()),
2578 extra,
2579 ..Default::default()
2580 }),
2581 };
2582 let md = render_path(&path, &RenderOptions::default());
2583
2584 assert!(md.contains("**PR #7**"));
2585 assert!(md.contains("by alice"));
2586 assert!(md.contains("merged"));
2588 assert!(!md.contains("closed"));
2589 }
2590
2591 #[test]
2592 fn test_catch_all_uses_friendly_name() {
2593 let mut step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z");
2595 step.change.insert(
2596 "review://some/path#L5".to_string(),
2597 ArtifactChange {
2598 raw: None,
2599 structural: Some(StructuralChange {
2600 change_type: "review.custom".into(),
2601 extra: Default::default(),
2602 }),
2603 },
2604 );
2605 let md = render_step(&step, &RenderOptions::default());
2606
2607 assert!(md.contains("some/path:5"));
2609 assert!(!md.contains("review://"));
2610 }
2611
2612 fn conv_append(role: &str, extras: &[(&str, serde_json::Value)]) -> ArtifactChange {
2615 let mut extra: std::collections::HashMap<String, serde_json::Value> =
2616 std::collections::HashMap::new();
2617 extra.insert("role".into(), serde_json::json!(role));
2618 for (k, v) in extras {
2619 extra.insert((*k).into(), v.clone());
2620 }
2621 ArtifactChange {
2622 raw: None,
2623 structural: Some(StructuralChange {
2624 change_type: "conversation.append".into(),
2625 extra,
2626 }),
2627 }
2628 }
2629
2630 fn agent_coding_session_path() -> Path {
2631 let key = "claude-code://sess-1";
2632
2633 let mut user = Step::new("u1", "human:user", "2026-01-01T00:00:00Z");
2634 user.change.insert(
2635 key.into(),
2636 conv_append("user", &[("text", serde_json::json!("add a greeting"))]),
2637 );
2638
2639 let mut asst = Step::new("a1", "agent:gpt-5.5", "2026-01-01T00:00:01Z");
2640 asst.step.parents = vec!["u1".into()];
2641 asst.change.insert(
2642 key.into(),
2643 conv_append(
2644 "assistant",
2645 &[
2646 ("text", serde_json::json!("done")),
2647 ("thinking", serde_json::json!("I'll edit main.rs")),
2648 ("stop_reason", serde_json::json!("tool_use")),
2649 (
2650 "token_usage",
2651 serde_json::json!({"input_tokens": 100, "output_tokens": 20, "cache_read_tokens": 50}),
2652 ),
2653 (
2654 "tool_uses",
2655 serde_json::json!([{
2656 "id": "c1", "name": "write_file",
2657 "input": {"file_path": "main.rs"},
2658 "category": "file_write",
2659 "result": {"content": "ok", "is_error": false}
2660 }]),
2661 ),
2662 ],
2663 ),
2664 );
2665 asst.change.insert(
2666 "main.rs".into(),
2667 ArtifactChange {
2668 raw: Some("@@ -0,0 +1 @@\n+fn main() {}".into()),
2669 structural: Some(StructuralChange {
2670 change_type: "file.write".into(),
2671 extra: std::collections::HashMap::from([(
2672 "operation".to_string(),
2673 serde_json::json!("add"),
2674 )]),
2675 }),
2676 },
2677 );
2678
2679 let mut ev = Step::new("e1", "tool:claude-code", "2026-01-01T00:00:02Z");
2680 ev.step.parents = vec!["a1".into()];
2681 ev.change.insert(
2682 key.into(),
2683 ArtifactChange {
2684 raw: None,
2685 structural: Some(StructuralChange {
2686 change_type: "conversation.event".into(),
2687 extra: std::collections::HashMap::from([(
2688 "entry_type".to_string(),
2689 serde_json::json!("attachment"),
2690 )]),
2691 }),
2692 },
2693 );
2694
2695 Path {
2696 path: PathIdentity {
2697 id: "p1".into(),
2698 base: None,
2699 head: "e1".into(),
2700 graph_ref: None,
2701 },
2702 steps: vec![user, asst, ev],
2703 meta: Some(PathMeta {
2704 kind: Some(toolpath::v1::PATH_KIND_AGENT_CODING_SESSION.into()),
2705 ..Default::default()
2706 }),
2707 }
2708 }
2709
2710 #[test]
2711 fn truncate_str_is_char_boundary_safe() {
2712 let s = format!("{}—tail", "a".repeat(198));
2715 let out = truncate_str(&s, 200);
2716 assert!(out.ends_with("..."));
2717 assert!(out.starts_with(&"a".repeat(198)));
2718 }
2719
2720 #[test]
2721 fn agent_coding_session_renders_flat_transcript() {
2722 let path = agent_coding_session_path();
2723 let md = render_path(
2724 &path,
2725 &RenderOptions {
2726 detail: Detail::Full,
2727 front_matter: false,
2728 },
2729 );
2730
2731 assert!(
2733 md.contains("**User:** add a greeting"),
2734 "user turn missing:\n{md}"
2735 );
2736 assert!(md.contains("**Assistant:** done"), "assistant turn missing");
2737 assert!(
2738 md.contains("**Reasoning:**") && md.contains("I'll edit main.rs"),
2739 "reasoning missing:\n{md}"
2740 );
2741 assert!(
2742 md.contains("**Tools:**") && md.contains("`write_file`") && md.contains("\u{2192} ok"),
2743 "tool call missing:\n{md}"
2744 );
2745 assert!(
2746 md.contains("tokens: 100 in, 20 out, 50 cached"),
2747 "token usage missing:\n{md}"
2748 );
2749 assert!(md.contains("stop: tool_use"), "stop reason missing");
2750 assert!(
2751 md.contains("wrote `main.rs`") && md.contains("(add)"),
2752 "file.write missing:\n{md}"
2753 );
2754
2755 assert!(!md.contains("### a1"), "step header leaked:\n{md}");
2758 assert!(!md.contains("**Timestamp:**"), "timestamp leaked:\n{md}");
2759 assert!(!md.contains("[dead end]"), "dead-end marker leaked:\n{md}");
2760 assert!(!md.contains("_attachment_"), "event noise leaked:\n{md}");
2761 assert!(
2762 !md.contains("## Timeline"),
2763 "timeline heading leaked:\n{md}"
2764 );
2765 }
2766
2767 #[test]
2768 fn agent_coding_session_summary_compacts_tool_calls() {
2769 let path = agent_coding_session_path();
2770 let md = render_path(&path, &RenderOptions::default()); assert!(md.contains("**User:** add a greeting"), "user:\n{md}");
2772 assert!(md.contains("**Assistant:** done"), "assistant:\n{md}");
2773 assert!(
2775 md.contains("*tools: write_file (1)*"),
2776 "tool breakdown:\n{md}"
2777 );
2778 assert!(
2779 !md.contains("```diff"),
2780 "summary should not emit diffs:\n{md}"
2781 );
2782 assert!(
2783 !md.contains("**Reasoning:**"),
2784 "summary omits reasoning:\n{md}"
2785 );
2786 }
2787
2788 #[test]
2789 fn agent_coding_session_summary_drops_empty_turns_and_breaks_down_tools() {
2790 let key = "claude-code://sess-1";
2792 let mut user = Step::new("u1", "human:user", "2026-01-01T00:00:00Z");
2793 user.change.insert(
2794 key.into(),
2795 conv_append("user", &[("text", serde_json::json!("go"))]),
2796 );
2797
2798 let mut work = Step::new("a1", "agent:gpt-5.5", "2026-01-01T00:00:01Z");
2799 work.step.parents = vec!["u1".into()];
2800 work.change.insert(
2801 key.into(),
2802 conv_append(
2803 "assistant",
2804 &[(
2805 "tool_uses",
2806 serde_json::json!([
2807 {"id": "1", "name": "Read", "input": {}, "category": "file_read"},
2808 {"id": "2", "name": "Read", "input": {}, "category": "file_read"},
2809 {"id": "3", "name": "Bash", "input": {}, "category": "shell"}
2810 ]),
2811 )],
2812 ),
2813 );
2814
2815 let mut reply = Step::new("a2", "agent:gpt-5.5", "2026-01-01T00:00:02Z");
2816 reply.step.parents = vec!["a1".into()];
2817 reply.change.insert(
2818 key.into(),
2819 conv_append(
2820 "assistant",
2821 &[
2822 ("text", serde_json::json!("ok")),
2823 ("tool_uses", serde_json::json!([{"id": "4", "name": "Read", "input": {}, "category": "file_read"}])),
2824 ],
2825 ),
2826 );
2827
2828 let path = Path {
2829 path: PathIdentity {
2830 id: "p1".into(),
2831 base: None,
2832 head: "a2".into(),
2833 graph_ref: None,
2834 },
2835 steps: vec![user, work, reply],
2836 meta: Some(PathMeta {
2837 kind: Some(toolpath::v1::PATH_KIND_AGENT_CODING_SESSION.into()),
2838 ..Default::default()
2839 }),
2840 };
2841
2842 let md = render_path(&path, &RenderOptions::default()); assert_eq!(
2846 md.matches("**Assistant:**").count(),
2847 1,
2848 "empty turn rendered:\n{md}"
2849 );
2850 assert!(md.contains("**User:** go"));
2851 assert!(md.contains("**Assistant:** ok"));
2852 assert!(
2854 md.contains("*tools: Read (2), Bash (1)*"),
2855 "breakdown:\n{md}"
2856 );
2857 }
2858
2859 #[test]
2860 fn agent_coding_session_omits_abandoned_turns() {
2861 let mut path = agent_coding_session_path();
2864 let mut dead = Step::new("d1", "agent:gpt-5.5", "2026-01-01T00:00:03Z");
2865 dead.step.parents = vec!["u1".into()];
2866 dead.change.insert(
2867 "claude-code://sess-1".into(),
2868 conv_append(
2869 "assistant",
2870 &[("text", serde_json::json!("abandoned attempt"))],
2871 ),
2872 );
2873 path.steps.push(dead);
2874
2875 let md = render_path(
2876 &path,
2877 &RenderOptions {
2878 detail: Detail::Full,
2879 front_matter: false,
2880 },
2881 );
2882 assert!(
2883 !md.contains("abandoned attempt"),
2884 "dead-end content shown:\n{md}"
2885 );
2886 assert!(
2887 md.contains("1 abandoned turn omitted"),
2888 "omission note:\n{md}"
2889 );
2890 }
2891
2892 #[test]
2893 fn without_kind_conversation_renders_generically() {
2894 let mut path = agent_coding_session_path();
2897 path.meta = None;
2898 let md = render_path(
2899 &path,
2900 &RenderOptions {
2901 detail: Detail::Full,
2902 front_matter: false,
2903 },
2904 );
2905 assert!(
2906 !md.contains("**Reasoning:**"),
2907 "kind treatment leaked:\n{md}"
2908 );
2909 assert!(
2910 md.contains("Structural: `conversation.append`"),
2911 "expected the generic structural dump:\n{md}"
2912 );
2913 }
2914}