1#![doc = include_str!("../README.md")]
2
3mod source;
4
5use std::collections::{HashMap, HashSet};
6use std::fmt::Write;
7
8use toolpath::v1::{ArtifactChange, Document, 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(doc: &Document, options: &RenderOptions) -> String {
54 match doc {
55 Document::Graph(g) => render_graph(g, options),
56 Document::Path(p) => render_path(p, options),
57 Document::Step(s) => render_step(s, options),
58 }
59}
60
61pub fn render_step(step: &Step, options: &RenderOptions) -> String {
63 let mut out = String::new();
64
65 if options.front_matter {
66 write_step_front_matter(&mut out, step);
67 }
68
69 writeln!(out, "# {}", step.step.id).unwrap();
70 writeln!(out).unwrap();
71 write_step_body(&mut out, step, options, false);
72
73 out
74}
75
76pub fn render_path(path: &Path, options: &RenderOptions) -> String {
78 let mut out = String::new();
79
80 if options.front_matter {
81 write_path_front_matter(&mut out, path);
82 }
83
84 let title = path
86 .meta
87 .as_ref()
88 .and_then(|m| m.title.as_deref())
89 .unwrap_or(&path.path.id);
90 writeln!(out, "# {title}").unwrap();
91 writeln!(out).unwrap();
92
93 write_path_context(&mut out, path);
95
96 let sorted = topo_sort(&path.steps);
98 let active = query::ancestors(&path.steps, &path.path.head);
99 let dead_end_set: HashSet<&str> = path
100 .steps
101 .iter()
102 .filter(|s| !active.contains(&s.step.id))
103 .map(|s| s.step.id.as_str())
104 .collect();
105
106 writeln!(out, "## Timeline").unwrap();
108 writeln!(out).unwrap();
109
110 for step in &sorted {
111 let is_dead = dead_end_set.contains(step.step.id.as_str());
112 let is_head = step.step.id == path.path.head;
113 write_path_step(&mut out, step, options, is_dead, is_head);
114 }
115
116 if !dead_end_set.is_empty() {
118 write_dead_ends_section(&mut out, &sorted, &dead_end_set);
119 }
120
121 write_review_section(&mut out, &sorted);
123
124 if let Some(meta) = &path.meta
126 && let Some(actors) = &meta.actors
127 {
128 write_actors_section(&mut out, actors);
129 }
130
131 out
132}
133
134pub fn render_graph(graph: &Graph, options: &RenderOptions) -> String {
136 let mut out = String::new();
137
138 if options.front_matter {
139 write_graph_front_matter(&mut out, graph);
140 }
141
142 let title = graph
144 .meta
145 .as_ref()
146 .and_then(|m| m.title.as_deref())
147 .unwrap_or(&graph.graph.id);
148 writeln!(out, "# {title}").unwrap();
149 writeln!(out).unwrap();
150
151 if let Some(meta) = &graph.meta
153 && let Some(intent) = &meta.intent
154 {
155 writeln!(out, "> {intent}").unwrap();
156 writeln!(out).unwrap();
157 }
158
159 if let Some(meta) = &graph.meta
161 && !meta.refs.is_empty()
162 {
163 for r in &meta.refs {
164 writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap();
165 }
166 writeln!(out).unwrap();
167 }
168
169 let inline_paths: Vec<&Path> = graph
171 .paths
172 .iter()
173 .filter_map(|por| match por {
174 PathOrRef::Path(p) => Some(p.as_ref()),
175 PathOrRef::Ref(_) => None,
176 })
177 .collect();
178
179 let ref_urls: Vec<&str> = graph
180 .paths
181 .iter()
182 .filter_map(|por| match por {
183 PathOrRef::Ref(r) => Some(r.ref_url.as_str()),
184 PathOrRef::Path(_) => None,
185 })
186 .collect();
187
188 if !inline_paths.is_empty() {
189 writeln!(out, "| Path | Steps | Actors | Head |").unwrap();
190 writeln!(out, "|------|-------|--------|------|").unwrap();
191 for path in &inline_paths {
192 let path_title = path
193 .meta
194 .as_ref()
195 .and_then(|m| m.title.as_deref())
196 .unwrap_or(&path.path.id);
197 let step_count = path.steps.len();
198 let actors = query::all_actors(&path.steps);
199 let actors_str = format_actor_list(&actors);
200 writeln!(
201 out,
202 "| {path_title} | {step_count} | {actors_str} | `{}` |",
203 path.path.head
204 )
205 .unwrap();
206 }
207 writeln!(out).unwrap();
208 }
209
210 if !ref_urls.is_empty() {
211 writeln!(out, "**External references:**").unwrap();
212 for url in &ref_urls {
213 writeln!(out, "- `{url}`").unwrap();
214 }
215 writeln!(out).unwrap();
216 }
217
218 for path in &inline_paths {
220 let path_title = path
221 .meta
222 .as_ref()
223 .and_then(|m| m.title.as_deref())
224 .unwrap_or(&path.path.id);
225 writeln!(out, "---").unwrap();
226 writeln!(out).unwrap();
227 writeln!(out, "## {path_title}").unwrap();
228 writeln!(out).unwrap();
229
230 write_path_context(&mut out, path);
231
232 let sorted = topo_sort(&path.steps);
233 let active = query::ancestors(&path.steps, &path.path.head);
234 let dead_end_set: HashSet<&str> = path
235 .steps
236 .iter()
237 .filter(|s| !active.contains(&s.step.id))
238 .map(|s| s.step.id.as_str())
239 .collect();
240
241 for step in &sorted {
242 let is_dead = dead_end_set.contains(step.step.id.as_str());
243 let is_head = step.step.id == path.path.head;
244 write_path_step(&mut out, step, options, is_dead, is_head);
245 }
246
247 if !dead_end_set.is_empty() {
248 write_dead_ends_section(&mut out, &sorted, &dead_end_set);
249 }
250 }
251
252 if let Some(meta) = &graph.meta
254 && let Some(actors) = &meta.actors
255 {
256 writeln!(out, "---").unwrap();
257 writeln!(out).unwrap();
258 write_actors_section(&mut out, actors);
259 }
260
261 out
262}
263
264fn write_step_body(out: &mut String, step: &Step, options: &RenderOptions, compact: bool) {
269 let heading = if compact { "###" } else { "##" };
270
271 writeln!(out, "**Actor:** `{}`", step.step.actor).unwrap();
273 writeln!(out, "**Timestamp:** {}", step.step.timestamp).unwrap();
274
275 if !step.step.parents.is_empty() {
277 let parents: Vec<String> = step.step.parents.iter().map(|p| format!("`{p}`")).collect();
278 writeln!(out, "**Parents:** {}", parents.join(", ")).unwrap();
279 }
280
281 writeln!(out).unwrap();
282
283 if let Some(meta) = &step.meta
285 && let Some(intent) = &meta.intent
286 {
287 writeln!(out, "> {intent}").unwrap();
288 writeln!(out).unwrap();
289 }
290
291 if let Some(meta) = &step.meta
293 && !meta.refs.is_empty()
294 {
295 for r in &meta.refs {
296 writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap();
297 }
298 writeln!(out).unwrap();
299 }
300
301 if !step.change.is_empty() {
303 writeln!(out, "{heading} Changes").unwrap();
304 writeln!(out).unwrap();
305
306 let mut artifacts: Vec<&String> = step.change.keys().collect();
307 artifacts.sort();
308
309 for artifact in artifacts {
310 let change = &step.change[artifact];
311 write_artifact_change(out, artifact, change, options);
312 }
313 }
314}
315
316fn write_artifact_change(
317 out: &mut String,
318 artifact: &str,
319 change: &ArtifactChange,
320 options: &RenderOptions,
321) {
322 let change_type = change
323 .structural
324 .as_ref()
325 .map(|s| s.change_type.as_str())
326 .unwrap_or("");
327
328 match options.detail {
329 Detail::Summary => match change_type {
330 "review.comment" | "review.conversation" => {
331 let display = friendly_artifact_name(artifact);
332 let body = change
333 .structural
334 .as_ref()
335 .and_then(|s| s.extra.get("body"))
336 .and_then(|v| v.as_str())
337 .unwrap_or("");
338 let truncated = truncate_str(body, 120);
339 if truncated.is_empty() {
340 writeln!(out, "- `{display}` (comment)").unwrap();
341 } else {
342 writeln!(out, "- `{display}` \u{2014} \"{truncated}\"").unwrap();
343 }
344 }
345 "review.decision" => {
346 let state = change
347 .structural
348 .as_ref()
349 .and_then(|s| s.extra.get("state"))
350 .and_then(|v| v.as_str())
351 .unwrap_or("COMMENTED");
352 let marker = review_state_marker(state);
353 let body = change.raw.as_deref().unwrap_or("");
354 let truncated = truncate_str(body, 120);
355 if truncated.is_empty() {
356 writeln!(out, "- {marker} {state}").unwrap();
357 } else {
358 writeln!(out, "- {marker} {state} \u{2014} \"{truncated}\"").unwrap();
359 }
360 }
361 "ci.run" => {
362 let name = friendly_artifact_name(artifact);
363 let conclusion = change
364 .structural
365 .as_ref()
366 .and_then(|s| s.extra.get("conclusion"))
367 .and_then(|v| v.as_str())
368 .unwrap_or("unknown");
369 let marker = ci_conclusion_marker(conclusion);
370 writeln!(out, "- {name} {marker} {conclusion}").unwrap();
371 }
372 _ => {
373 let display = friendly_artifact_name(artifact);
374 let annotation = change_annotation(change);
375 writeln!(out, "- `{display}`{annotation}").unwrap();
376 }
377 },
378 Detail::Full => {
379 match change_type {
380 "review.comment" | "review.conversation" => {
381 let display = friendly_artifact_name(artifact);
382 writeln!(out, "**`{display}`**").unwrap();
383 let body = change
384 .structural
385 .as_ref()
386 .and_then(|s| s.extra.get("body"))
387 .and_then(|v| v.as_str())
388 .unwrap_or("");
389 if !body.is_empty() {
390 writeln!(out).unwrap();
391 for line in body.lines() {
392 writeln!(out, "> {line}").unwrap();
393 }
394 }
395 if let Some(raw) = &change.raw {
397 writeln!(out).unwrap();
398 writeln!(out, "```diff").unwrap();
399 writeln!(out, "{raw}").unwrap();
400 writeln!(out, "```").unwrap();
401 }
402 writeln!(out).unwrap();
403 }
404 "review.decision" => {
405 let state = change
406 .structural
407 .as_ref()
408 .and_then(|s| s.extra.get("state"))
409 .and_then(|v| v.as_str())
410 .unwrap_or("COMMENTED");
411 let marker = review_state_marker(state);
412 writeln!(out, "**{marker} {state}**").unwrap();
413 if let Some(raw) = &change.raw {
414 writeln!(out).unwrap();
415 for line in raw.lines() {
416 writeln!(out, "> {line}").unwrap();
417 }
418 }
419 writeln!(out).unwrap();
420 }
421 "ci.run" => {
422 let name = friendly_artifact_name(artifact);
423 let conclusion = change
424 .structural
425 .as_ref()
426 .and_then(|s| s.extra.get("conclusion"))
427 .and_then(|v| v.as_str())
428 .unwrap_or("unknown");
429 let marker = ci_conclusion_marker(conclusion);
430 write!(out, "**{name}** {marker} {conclusion}").unwrap();
431 if let Some(url) = change
432 .structural
433 .as_ref()
434 .and_then(|s| s.extra.get("url"))
435 .and_then(|v| v.as_str())
436 {
437 write!(out, " ([details]({url}))").unwrap();
438 }
439 writeln!(out).unwrap();
440 writeln!(out).unwrap();
441 }
442 _ => {
443 let display = friendly_artifact_name(artifact);
444 writeln!(out, "**`{display}`**").unwrap();
445 if let Some(raw) = &change.raw {
446 writeln!(out).unwrap();
447 writeln!(out, "```diff").unwrap();
448 writeln!(out, "{raw}").unwrap();
449 writeln!(out, "```").unwrap();
450 }
451 if let Some(structural) = &change.structural {
452 writeln!(out).unwrap();
453 let extra_str = if structural.extra.is_empty() {
454 String::new()
455 } else {
456 let pairs: Vec<String> = structural
457 .extra
458 .iter()
459 .map(|(k, v)| format!("{k}={v}"))
460 .collect();
461 format!(" ({})", pairs.join(", "))
462 };
463 writeln!(out, "Structural: `{}`{extra_str}", structural.change_type)
464 .unwrap();
465 }
466 writeln!(out).unwrap();
467 }
468 }
469 }
470 }
471}
472
473fn change_annotation(change: &ArtifactChange) -> String {
474 let mut parts = Vec::new();
475
476 if let Some(raw) = &change.raw {
477 let (add, del) = count_diff_lines(raw);
478 if add > 0 || del > 0 {
479 parts.push(format!("+{add} -{del}"));
480 }
481 }
482
483 if let Some(structural) = &change.structural {
484 parts.push(structural.change_type.clone());
485 }
486
487 if parts.is_empty() {
488 String::new()
489 } else {
490 format!(" ({})", parts.join(", "))
491 }
492}
493
494fn count_diff_lines(raw: &str) -> (usize, usize) {
495 let mut add = 0;
496 let mut del = 0;
497 for line in raw.lines() {
498 if line.starts_with('+') && !line.starts_with("+++") {
499 add += 1;
500 } else if line.starts_with('-') && !line.starts_with("---") {
501 del += 1;
502 }
503 }
504 (add, del)
505}
506
507fn write_path_step(
508 out: &mut String,
509 step: &Step,
510 options: &RenderOptions,
511 is_dead: bool,
512 is_head: bool,
513) {
514 let actor_short = actor_display(&step.step.actor);
516 let markers = match (is_dead, is_head) {
517 (true, _) => " [dead end]",
518 (_, true) => " [head]",
519 _ => "",
520 };
521
522 writeln!(
523 out,
524 "### {} \u{2014} {}{}",
525 step.step.id, actor_short, markers
526 )
527 .unwrap();
528 writeln!(out).unwrap();
529
530 writeln!(out, "**Timestamp:** {}", step.step.timestamp).unwrap();
531
532 if !step.step.parents.is_empty() {
534 let parents: Vec<String> = step.step.parents.iter().map(|p| format!("`{p}`")).collect();
535 writeln!(out, "**Parents:** {}", parents.join(", ")).unwrap();
536 }
537
538 writeln!(out).unwrap();
539
540 if let Some(meta) = &step.meta
542 && let Some(intent) = &meta.intent
543 {
544 writeln!(out, "> {intent}").unwrap();
545 writeln!(out).unwrap();
546 }
547
548 if let Some(meta) = &step.meta
550 && !meta.refs.is_empty()
551 {
552 for r in &meta.refs {
553 writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap();
554 }
555 writeln!(out).unwrap();
556 }
557
558 if !step.change.is_empty() {
560 let mut artifacts: Vec<&String> = step.change.keys().collect();
561 artifacts.sort();
562
563 for artifact in artifacts {
564 let change = &step.change[artifact];
565 write_artifact_change(out, artifact, change, options);
566 }
567 if options.detail == Detail::Summary {
568 writeln!(out).unwrap();
569 }
570 }
571}
572
573fn write_path_context(out: &mut String, path: &Path) {
574 let ctx = source::detect(path);
575
576 if let Some(identity) = &ctx.identity_line {
577 writeln!(out, "{identity}").unwrap();
578 }
579
580 if let Some(base) = &path.path.base {
581 write!(out, "**Base:** `{}`", base.uri).unwrap();
582 if let Some(ref_str) = &base.ref_str {
583 write!(out, " @ `{ref_str}`").unwrap();
584 }
585 writeln!(out).unwrap();
586 }
587
588 if ctx.identity_line.is_none() {
590 writeln!(out, "**Head:** `{}`", path.path.head).unwrap();
591 }
592
593 if let Some(meta) = &path.meta {
594 if let Some(source) = &meta.source {
595 writeln!(out, "**Source:** `{source}`").unwrap();
596 }
597 if let Some(intent) = &meta.intent {
598 writeln!(out, "**Intent:** {intent}").unwrap();
599 }
600 if !meta.refs.is_empty() {
601 for r in &meta.refs {
602 writeln!(out, "- **{}:** `{}`", r.rel, r.href).unwrap();
603 }
604 }
605 }
606
607 let (total_add, total_del, file_count) = ctx
609 .diffstat
610 .unwrap_or_else(|| count_total_diff_lines(&path.steps));
611
612 if total_add > 0 || total_del > 0 {
613 write!(out, "**Changes:** +{total_add} \u{2212}{total_del}").unwrap();
614 if let Some(f) = file_count {
615 write!(out, " across {f} files").unwrap();
616 }
617 writeln!(out).unwrap();
618 }
619
620 let artifacts = query::all_artifacts(&path.steps);
622 let dead_ends = query::dead_ends(&path.steps, &path.path.head);
623 writeln!(
624 out,
625 "**Steps:** {} | **Artifacts:** {} | **Dead ends:** {}",
626 path.steps.len(),
627 artifacts.len(),
628 dead_ends.len()
629 )
630 .unwrap();
631
632 writeln!(out).unwrap();
633}
634
635fn write_dead_ends_section(out: &mut String, sorted: &[&Step], dead_end_set: &HashSet<&str>) {
636 writeln!(out, "## Dead Ends").unwrap();
637 writeln!(out).unwrap();
638 writeln!(
639 out,
640 "These steps were attempted but did not contribute to the final result."
641 )
642 .unwrap();
643 writeln!(out).unwrap();
644
645 for step in sorted {
646 if !dead_end_set.contains(step.step.id.as_str()) {
647 continue;
648 }
649 let intent = step
650 .meta
651 .as_ref()
652 .and_then(|m| m.intent.as_deref())
653 .unwrap_or("(no intent recorded)");
654 let parents: Vec<String> = step.step.parents.iter().map(|p| format!("`{p}`")).collect();
655 let parent_str = if parents.is_empty() {
656 "root".to_string()
657 } else {
658 parents.join(", ")
659 };
660 writeln!(
661 out,
662 "- **{}** ({}) \u{2014} {} | Parent: {}",
663 step.step.id, step.step.actor, intent, parent_str
664 )
665 .unwrap();
666 }
667 writeln!(out).unwrap();
668}
669
670fn write_review_section(out: &mut String, sorted: &[&Step]) {
671 struct ReviewDecision<'a> {
673 state: &'a str,
674 actor: &'a str,
675 body: Option<&'a str>,
676 }
677 struct ReviewComment<'a> {
678 artifact: String,
679 actor: &'a str,
680 body: &'a str,
681 diff_hunk: Option<&'a str>,
682 }
683 struct ConversationComment<'a> {
684 actor: &'a str,
685 body: &'a str,
686 }
687
688 let mut decisions: Vec<ReviewDecision<'_>> = Vec::new();
689 let mut comments: Vec<ReviewComment<'_>> = Vec::new();
690 let mut conversations: Vec<ConversationComment<'_>> = Vec::new();
691
692 for step in sorted {
693 for (artifact, change) in &step.change {
694 let change_type = change
695 .structural
696 .as_ref()
697 .map(|s| s.change_type.as_str())
698 .unwrap_or("");
699 match change_type {
700 "review.decision" => {
701 let state = change
702 .structural
703 .as_ref()
704 .and_then(|s| s.extra.get("state"))
705 .and_then(|v| v.as_str())
706 .unwrap_or("COMMENTED");
707 let body = change.raw.as_deref();
708 decisions.push(ReviewDecision {
709 state,
710 actor: &step.step.actor,
711 body,
712 });
713 }
714 "review.comment" => {
715 let body = change
716 .structural
717 .as_ref()
718 .and_then(|s| s.extra.get("body"))
719 .and_then(|v| v.as_str())
720 .unwrap_or("");
721 if !body.is_empty() {
722 comments.push(ReviewComment {
723 artifact: friendly_artifact_name(artifact),
724 actor: &step.step.actor,
725 body,
726 diff_hunk: change.raw.as_deref(),
727 });
728 }
729 }
730 "review.conversation" => {
731 let body = change
732 .structural
733 .as_ref()
734 .and_then(|s| s.extra.get("body"))
735 .and_then(|v| v.as_str())
736 .unwrap_or("");
737 if !body.is_empty() {
738 conversations.push(ConversationComment {
739 actor: &step.step.actor,
740 body,
741 });
742 }
743 }
744 _ => {}
745 }
746 }
747 }
748
749 if decisions.is_empty() && comments.is_empty() && conversations.is_empty() {
750 return;
751 }
752
753 writeln!(out, "## Review").unwrap();
754 writeln!(out).unwrap();
755
756 for d in &decisions {
757 let marker = review_state_marker(d.state);
758 let actor_short = d.actor.split(':').next_back().unwrap_or(d.actor);
759 write!(out, "**{} {}** by {actor_short}", marker, d.state).unwrap();
760 if let Some(body) = d.body
761 && !body.is_empty()
762 {
763 writeln!(out, ":").unwrap();
764 for line in body.lines() {
765 writeln!(out, "> {line}").unwrap();
766 }
767 } else {
768 writeln!(out).unwrap();
769 }
770 writeln!(out).unwrap();
771 }
772
773 if !conversations.is_empty() {
774 writeln!(out, "### Discussion").unwrap();
775 writeln!(out).unwrap();
776
777 for c in &conversations {
778 let actor_short = c.actor.split(':').next_back().unwrap_or(c.actor);
779 writeln!(out, "**{actor_short}:**").unwrap();
780 for line in c.body.lines() {
781 writeln!(out, "> {line}").unwrap();
782 }
783 writeln!(out).unwrap();
784 }
785 }
786
787 if !comments.is_empty() {
788 writeln!(out, "### Inline comments").unwrap();
789 writeln!(out).unwrap();
790
791 for c in &comments {
792 let actor_short = c.actor.split(':').next_back().unwrap_or(c.actor);
793 writeln!(out, "**{}** \u{2014} {actor_short}:", c.artifact).unwrap();
794 for line in c.body.lines() {
795 writeln!(out, "> {line}").unwrap();
796 }
797 if let Some(hunk) = c.diff_hunk {
798 writeln!(out).unwrap();
799 writeln!(out, "```diff").unwrap();
800 writeln!(out, "{hunk}").unwrap();
801 writeln!(out, "```").unwrap();
802 }
803 writeln!(out).unwrap();
804 }
805 }
806}
807
808fn write_actors_section(out: &mut String, actors: &HashMap<String, toolpath::v1::ActorDefinition>) {
809 writeln!(out, "## Actors").unwrap();
810 writeln!(out).unwrap();
811
812 let mut keys: Vec<&String> = actors.keys().collect();
813 keys.sort();
814
815 for key in keys {
816 let def = &actors[key];
817 let name = def.name.as_deref().unwrap_or(key);
818 write!(out, "- **`{key}`** \u{2014} {name}").unwrap();
819 if let Some(provider) = &def.provider {
820 write!(out, " ({provider}").unwrap();
821 if let Some(model) = &def.model {
822 write!(out, ", {model}").unwrap();
823 }
824 write!(out, ")").unwrap();
825 }
826 writeln!(out).unwrap();
827 }
828 writeln!(out).unwrap();
829}
830
831fn write_step_front_matter(out: &mut String, step: &Step) {
836 writeln!(out, "---").unwrap();
837 writeln!(out, "type: step").unwrap();
838 writeln!(out, "id: {}", step.step.id).unwrap();
839 writeln!(out, "actor: {}", step.step.actor).unwrap();
840 writeln!(out, "timestamp: {}", step.step.timestamp).unwrap();
841 if !step.step.parents.is_empty() {
842 let parents: Vec<&str> = step.step.parents.iter().map(|s| s.as_str()).collect();
843 writeln!(out, "parents: [{}]", parents.join(", ")).unwrap();
844 }
845 let mut artifacts: Vec<&str> = step.change.keys().map(|k| k.as_str()).collect();
846 artifacts.sort();
847 if !artifacts.is_empty() {
848 writeln!(out, "artifacts: [{}]", artifacts.join(", ")).unwrap();
849 }
850 writeln!(out, "---").unwrap();
851 writeln!(out).unwrap();
852}
853
854fn write_path_front_matter(out: &mut String, path: &Path) {
855 writeln!(out, "---").unwrap();
856 writeln!(out, "type: path").unwrap();
857 writeln!(out, "id: {}", path.path.id).unwrap();
858 writeln!(out, "head: {}", path.path.head).unwrap();
859 if let Some(base) = &path.path.base {
860 writeln!(out, "base: {}", base.uri).unwrap();
861 if let Some(ref_str) = &base.ref_str {
862 writeln!(out, "base_ref: {ref_str}").unwrap();
863 }
864 }
865 writeln!(out, "steps: {}", path.steps.len()).unwrap();
866 let actors = query::all_actors(&path.steps);
867 let mut actor_list: Vec<&str> = actors.iter().copied().collect();
868 actor_list.sort();
869 writeln!(out, "actors: [{}]", actor_list.join(", ")).unwrap();
870 let mut artifacts: Vec<&str> = query::all_artifacts(&path.steps).into_iter().collect();
871 artifacts.sort();
872 writeln!(out, "artifacts: [{}]", artifacts.join(", ")).unwrap();
873 let dead_ends = query::dead_ends(&path.steps, &path.path.head);
874 writeln!(out, "dead_ends: {}", dead_ends.len()).unwrap();
875 writeln!(out, "---").unwrap();
876 writeln!(out).unwrap();
877}
878
879fn write_graph_front_matter(out: &mut String, graph: &Graph) {
880 writeln!(out, "---").unwrap();
881 writeln!(out, "type: graph").unwrap();
882 writeln!(out, "id: {}", graph.graph.id).unwrap();
883 let inline_count = graph
884 .paths
885 .iter()
886 .filter(|p| matches!(p, PathOrRef::Path(_)))
887 .count();
888 let ref_count = graph
889 .paths
890 .iter()
891 .filter(|p| matches!(p, PathOrRef::Ref(_)))
892 .count();
893 writeln!(out, "paths: {inline_count}").unwrap();
894 if ref_count > 0 {
895 writeln!(out, "external_refs: {ref_count}").unwrap();
896 }
897 writeln!(out, "---").unwrap();
898 writeln!(out).unwrap();
899}
900
901fn actor_display(actor: &str) -> &str {
910 actor
911}
912
913fn friendly_artifact_name(artifact: &str) -> String {
919 if let Some(rest) = artifact.strip_prefix("review://") {
920 if let Some(pos) = rest.rfind("#L") {
921 format!("{}:{}", &rest[..pos], &rest[pos + 2..])
922 } else {
923 rest.to_string()
924 }
925 } else if let Some(rest) = artifact.strip_prefix("ci://checks/") {
926 rest.to_string()
927 } else {
928 artifact.to_string()
929 }
930}
931
932fn truncate_str(s: &str, max: usize) -> String {
934 let s = s.lines().collect::<Vec<_>>().join(" ").trim().to_string();
935 if s.len() <= max {
936 s
937 } else {
938 format!("{}...", &s[..max])
939 }
940}
941
942fn review_state_marker(state: &str) -> &'static str {
944 match state {
945 "APPROVED" => "[approved]",
946 "CHANGES_REQUESTED" => "[changes requested]",
947 "COMMENTED" => "[commented]",
948 "DISMISSED" => "[dismissed]",
949 _ => "[review]",
950 }
951}
952
953fn count_total_diff_lines(steps: &[Step]) -> (u64, u64, Option<u64>) {
955 let mut total_add: u64 = 0;
956 let mut total_del: u64 = 0;
957 let mut files: HashSet<&str> = HashSet::new();
958 for step in steps {
959 for (artifact, change) in &step.change {
960 if artifact.starts_with("review://") || artifact.starts_with("ci://") {
962 continue;
963 }
964 if let Some(raw) = &change.raw {
965 let (a, d) = count_diff_lines(raw);
966 total_add += a as u64;
967 total_del += d as u64;
968 files.insert(artifact.as_str());
969 }
970 }
971 }
972 let file_count = if files.is_empty() {
973 None
974 } else {
975 Some(files.len() as u64)
976 };
977 (total_add, total_del, file_count)
978}
979
980pub(crate) fn friendly_date_range(steps: &[Step]) -> String {
983 if steps.is_empty() {
984 return String::new();
985 }
986
987 let mut first: Option<&str> = None;
988 let mut last: Option<&str> = None;
989
990 for step in steps {
991 let ts = step.step.timestamp.as_str();
992 if ts.is_empty() || ts.starts_with("1970") {
993 continue;
994 }
995 match first {
996 None => {
997 first = Some(ts);
998 last = Some(ts);
999 }
1000 Some(f) => {
1001 if ts < f {
1002 first = Some(ts);
1003 }
1004 if ts > last.unwrap_or("") {
1005 last = Some(ts);
1006 }
1007 }
1008 }
1009 }
1010
1011 let Some(first) = first else {
1012 return String::new();
1013 };
1014 let last = last.unwrap_or(first);
1015
1016 let first_date = &first[..first.len().min(10)];
1018 let last_date = &last[..last.len().min(10)];
1019
1020 let Some(first_fmt) = format_date(first_date) else {
1021 return String::new();
1022 };
1023
1024 if first_date == last_date {
1025 return first_fmt;
1026 }
1027
1028 let Some(last_fmt) = format_date(last_date) else {
1029 return first_fmt;
1030 };
1031
1032 let first_parts: Vec<&str> = first_date.split('-').collect();
1034 let last_parts: Vec<&str> = last_date.split('-').collect();
1035
1036 if first_parts.len() == 3 && last_parts.len() == 3 {
1037 if first_parts[0] == last_parts[0] && first_parts[1] == last_parts[1] {
1038 let month = month_abbrev(first_parts[1]);
1040 let day1 = first_parts[2].trim_start_matches('0');
1041 let day2 = last_parts[2].trim_start_matches('0');
1042 return format!("{month} {day1}\u{2013}{day2}, {}", first_parts[0]);
1043 }
1044 if first_parts[0] == last_parts[0] {
1045 let month1 = month_abbrev(first_parts[1]);
1047 let day1 = first_parts[2].trim_start_matches('0');
1048 let month2 = month_abbrev(last_parts[1]);
1049 let day2 = last_parts[2].trim_start_matches('0');
1050 return format!(
1051 "{month1} {day1} \u{2013} {month2} {day2}, {}",
1052 first_parts[0]
1053 );
1054 }
1055 }
1056
1057 format!("{first_fmt} \u{2013} {last_fmt}")
1059}
1060
1061fn format_date(date: &str) -> Option<String> {
1063 let parts: Vec<&str> = date.split('-').collect();
1064 if parts.len() != 3 {
1065 return None;
1066 }
1067 let month = month_abbrev(parts[1]);
1068 let day = parts[2].trim_start_matches('0');
1069 Some(format!("{month} {day}, {}", parts[0]))
1070}
1071
1072fn month_abbrev(month: &str) -> &'static str {
1073 match month {
1074 "01" => "Jan",
1075 "02" => "Feb",
1076 "03" => "Mar",
1077 "04" => "Apr",
1078 "05" => "May",
1079 "06" => "Jun",
1080 "07" => "Jul",
1081 "08" => "Aug",
1082 "09" => "Sep",
1083 "10" => "Oct",
1084 "11" => "Nov",
1085 "12" => "Dec",
1086 _ => "???",
1087 }
1088}
1089
1090fn ci_conclusion_marker(conclusion: &str) -> &'static str {
1092 match conclusion {
1093 "success" => "[pass]",
1094 "failure" => "[fail]",
1095 "cancelled" | "timed_out" => "[cancelled]",
1096 "skipped" => "[skip]",
1097 "neutral" => "[neutral]",
1098 _ => "[unknown]",
1099 }
1100}
1101
1102fn format_actor_list(actors: &HashSet<&str>) -> String {
1104 let mut list: Vec<&str> = actors.iter().copied().collect();
1105 list.sort();
1106 list.iter()
1107 .map(|a| format!("`{a}`"))
1108 .collect::<Vec<_>>()
1109 .join(", ")
1110}
1111
1112fn topo_sort<'a>(steps: &'a [Step]) -> Vec<&'a Step> {
1115 let index: HashMap<&str, &Step> = steps.iter().map(|s| (s.step.id.as_str(), s)).collect();
1116 let ids: Vec<&str> = steps.iter().map(|s| s.step.id.as_str()).collect();
1117 let id_set: HashSet<&str> = ids.iter().copied().collect();
1118
1119 let mut in_degree: HashMap<&str, usize> = HashMap::new();
1121 let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
1122
1123 for &id in &ids {
1124 in_degree.entry(id).or_insert(0);
1125 children.entry(id).or_default();
1126 }
1127
1128 for step in steps {
1129 for parent in &step.step.parents {
1130 if id_set.contains(parent.as_str()) {
1131 *in_degree.entry(step.step.id.as_str()).or_insert(0) += 1;
1132 children
1133 .entry(parent.as_str())
1134 .or_default()
1135 .push(step.step.id.as_str());
1136 }
1137 }
1138 }
1139
1140 let mut queue: Vec<&str> = ids
1142 .iter()
1143 .copied()
1144 .filter(|id| in_degree.get(id).copied().unwrap_or(0) == 0)
1145 .collect();
1146
1147 let mut result: Vec<&'a Step> = Vec::with_capacity(steps.len());
1148
1149 while let Some(id) = queue.first().copied() {
1150 queue.remove(0);
1151 if let Some(step) = index.get(id) {
1152 result.push(step);
1153 }
1154 if let Some(kids) = children.get(id) {
1155 for &child in kids {
1156 let deg = in_degree.get_mut(child).unwrap();
1157 *deg -= 1;
1158 if *deg == 0 {
1159 queue.push(child);
1160 }
1161 }
1162 }
1163 }
1164
1165 let placed: HashSet<&str> = result.iter().map(|s| s.step.id.as_str()).collect();
1167 for step in steps {
1168 if !placed.contains(step.step.id.as_str()) {
1169 result.push(step);
1170 }
1171 }
1172
1173 result
1174}
1175
1176#[cfg(test)]
1177mod tests {
1178 use super::*;
1179 use toolpath::v1::{
1180 Base, Graph, GraphIdentity, GraphMeta, Path, PathIdentity, PathMeta, PathOrRef, PathRef,
1181 Ref, Step, StructuralChange,
1182 };
1183
1184 fn make_step(id: &str, actor: &str, parents: &[&str]) -> Step {
1185 let mut step = Step::new(id, actor, "2026-01-29T10:00:00Z")
1186 .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new");
1187 for p in parents {
1188 step = step.with_parent(*p);
1189 }
1190 step
1191 }
1192
1193 fn make_step_with_intent(id: &str, actor: &str, parents: &[&str], intent: &str) -> Step {
1194 make_step(id, actor, parents).with_intent(intent)
1195 }
1196
1197 #[test]
1200 fn test_render_step_basic() {
1201 let step = make_step("s1", "human:alex", &[]);
1202 let opts = RenderOptions::default();
1203 let md = render_step(&step, &opts);
1204
1205 assert!(md.starts_with("# s1"));
1206 assert!(md.contains("human:alex"));
1207 assert!(md.contains("src/main.rs"));
1208 }
1209
1210 #[test]
1211 fn test_render_step_with_intent() {
1212 let step = make_step_with_intent("s1", "human:alex", &[], "Fix the bug");
1213 let opts = RenderOptions::default();
1214 let md = render_step(&step, &opts);
1215
1216 assert!(md.contains("> Fix the bug"));
1217 }
1218
1219 #[test]
1220 fn test_render_step_with_parents() {
1221 let step = make_step("s2", "agent:claude", &["s1"]);
1222 let opts = RenderOptions::default();
1223 let md = render_step(&step, &opts);
1224
1225 assert!(md.contains("`s1`"));
1226 }
1227
1228 #[test]
1229 fn test_render_step_with_front_matter() {
1230 let step = make_step("s1", "human:alex", &[]);
1231 let opts = RenderOptions {
1232 front_matter: true,
1233 ..Default::default()
1234 };
1235 let md = render_step(&step, &opts);
1236
1237 assert!(md.starts_with("---\n"));
1238 assert!(md.contains("type: step"));
1239 assert!(md.contains("id: s1"));
1240 assert!(md.contains("actor: human:alex"));
1241 }
1242
1243 #[test]
1244 fn test_render_step_full_detail() {
1245 let step = make_step("s1", "human:alex", &[]);
1246 let opts = RenderOptions {
1247 detail: Detail::Full,
1248 ..Default::default()
1249 };
1250 let md = render_step(&step, &opts);
1251
1252 assert!(md.contains("```diff"));
1253 assert!(md.contains("-old"));
1254 assert!(md.contains("+new"));
1255 }
1256
1257 #[test]
1258 fn test_render_step_summary_has_diffstat() {
1259 let step = make_step("s1", "human:alex", &[]);
1260 let opts = RenderOptions::default();
1261 let md = render_step(&step, &opts);
1262
1263 assert!(md.contains("+1 -1"));
1264 }
1265
1266 #[test]
1269 fn test_render_path_basic() {
1270 let s1 = make_step_with_intent("s1", "human:alex", &[], "Start");
1271 let s2 = make_step_with_intent("s2", "agent:claude", &["s1"], "Continue");
1272 let path = Path {
1273 path: PathIdentity {
1274 id: "p1".into(),
1275 base: Some(Base::vcs("github:org/repo", "abc123")),
1276 head: "s2".into(),
1277 },
1278 steps: vec![s1, s2],
1279 meta: Some(PathMeta {
1280 title: Some("My PR".into()),
1281 ..Default::default()
1282 }),
1283 };
1284 let opts = RenderOptions::default();
1285 let md = render_path(&path, &opts);
1286
1287 assert!(md.starts_with("# My PR"));
1288 assert!(md.contains("github:org/repo"));
1289 assert!(md.contains("## Timeline"));
1290 assert!(md.contains("### s1"));
1291 assert!(md.contains("### s2"));
1292 assert!(md.contains("[head]"));
1293 }
1294
1295 #[test]
1296 fn test_render_path_with_dead_ends() {
1297 let s1 = make_step("s1", "human:alex", &[]);
1298 let s2 = make_step_with_intent("s2", "agent:claude", &["s1"], "Good approach");
1299 let s2a = make_step_with_intent("s2a", "agent:claude", &["s1"], "Bad approach (abandoned)");
1300 let s3 = make_step("s3", "human:alex", &["s2"]);
1301 let path = Path {
1302 path: PathIdentity {
1303 id: "p1".into(),
1304 base: None,
1305 head: "s3".into(),
1306 },
1307 steps: vec![s1, s2, s2a, s3],
1308 meta: None,
1309 };
1310 let opts = RenderOptions::default();
1311 let md = render_path(&path, &opts);
1312
1313 assert!(md.contains("[dead end]"));
1314 assert!(md.contains("## Dead Ends"));
1315 assert!(md.contains("Bad approach (abandoned)"));
1316 }
1317
1318 #[test]
1319 fn test_render_path_with_front_matter() {
1320 let s1 = make_step("s1", "human:alex", &[]);
1321 let path = Path {
1322 path: PathIdentity {
1323 id: "p1".into(),
1324 base: None,
1325 head: "s1".into(),
1326 },
1327 steps: vec![s1],
1328 meta: None,
1329 };
1330 let opts = RenderOptions {
1331 front_matter: true,
1332 ..Default::default()
1333 };
1334 let md = render_path(&path, &opts);
1335
1336 assert!(md.starts_with("---\n"));
1337 assert!(md.contains("type: path"));
1338 assert!(md.contains("id: p1"));
1339 assert!(md.contains("steps: 1"));
1340 assert!(md.contains("dead_ends: 0"));
1341 }
1342
1343 #[test]
1344 fn test_render_path_stats_line() {
1345 let s1 = make_step("s1", "human:alex", &[]);
1346 let s2 = make_step("s2", "agent:claude", &["s1"]);
1347 let path = Path {
1348 path: PathIdentity {
1349 id: "p1".into(),
1350 base: None,
1351 head: "s2".into(),
1352 },
1353 steps: vec![s1, s2],
1354 meta: None,
1355 };
1356 let md = render_path(&path, &RenderOptions::default());
1357
1358 assert!(md.contains("**Steps:** 2"));
1359 assert!(md.contains("**Artifacts:** 1"));
1360 assert!(md.contains("**Dead ends:** 0"));
1361 }
1362
1363 #[test]
1364 fn test_render_path_with_refs() {
1365 let s1 = make_step("s1", "human:alex", &[]);
1366 let path = Path {
1367 path: PathIdentity {
1368 id: "p1".into(),
1369 base: None,
1370 head: "s1".into(),
1371 },
1372 steps: vec![s1],
1373 meta: Some(PathMeta {
1374 refs: vec![Ref {
1375 rel: "fixes".into(),
1376 href: "issue://github/org/repo/issues/42".into(),
1377 }],
1378 ..Default::default()
1379 }),
1380 };
1381 let md = render_path(&path, &RenderOptions::default());
1382
1383 assert!(md.contains("**fixes:**"));
1384 assert!(md.contains("issue://github/org/repo/issues/42"));
1385 }
1386
1387 #[test]
1390 fn test_render_graph_basic() {
1391 let s1 = make_step_with_intent("s1", "human:alex", &[], "First");
1392 let s2 = make_step_with_intent("s2", "agent:claude", &["s1"], "Second");
1393 let path1 = Path {
1394 path: PathIdentity {
1395 id: "p1".into(),
1396 base: Some(Base::vcs("github:org/repo", "abc")),
1397 head: "s2".into(),
1398 },
1399 steps: vec![s1, s2],
1400 meta: Some(PathMeta {
1401 title: Some("PR #42".into()),
1402 ..Default::default()
1403 }),
1404 };
1405
1406 let s3 = make_step("s3", "human:bob", &[]);
1407 let path2 = Path {
1408 path: PathIdentity {
1409 id: "p2".into(),
1410 base: None,
1411 head: "s3".into(),
1412 },
1413 steps: vec![s3],
1414 meta: Some(PathMeta {
1415 title: Some("PR #43".into()),
1416 ..Default::default()
1417 }),
1418 };
1419
1420 let graph = Graph {
1421 graph: GraphIdentity { id: "g1".into() },
1422 paths: vec![
1423 PathOrRef::Path(Box::new(path1)),
1424 PathOrRef::Path(Box::new(path2)),
1425 ],
1426 meta: Some(GraphMeta {
1427 title: Some("Release v2.0".into()),
1428 ..Default::default()
1429 }),
1430 };
1431 let opts = RenderOptions::default();
1432 let md = render_graph(&graph, &opts);
1433
1434 assert!(md.starts_with("# Release v2.0"));
1435 assert!(md.contains("| PR #42"));
1436 assert!(md.contains("| PR #43"));
1437 assert!(md.contains("## PR #42"));
1438 assert!(md.contains("## PR #43"));
1439 }
1440
1441 #[test]
1442 fn test_render_graph_with_refs() {
1443 let graph = Graph {
1444 graph: GraphIdentity { id: "g1".into() },
1445 paths: vec![PathOrRef::Ref(PathRef {
1446 ref_url: "https://example.com/path.json".into(),
1447 })],
1448 meta: None,
1449 };
1450 let md = render_graph(&graph, &RenderOptions::default());
1451
1452 assert!(md.contains("External references"));
1453 assert!(md.contains("example.com/path.json"));
1454 }
1455
1456 #[test]
1457 fn test_render_graph_with_front_matter() {
1458 let s1 = make_step("s1", "human:alex", &[]);
1459 let path1 = Path {
1460 path: PathIdentity {
1461 id: "p1".into(),
1462 base: None,
1463 head: "s1".into(),
1464 },
1465 steps: vec![s1],
1466 meta: None,
1467 };
1468 let graph = Graph {
1469 graph: GraphIdentity { id: "g1".into() },
1470 paths: vec![
1471 PathOrRef::Path(Box::new(path1)),
1472 PathOrRef::Ref(PathRef {
1473 ref_url: "https://example.com".into(),
1474 }),
1475 ],
1476 meta: None,
1477 };
1478 let opts = RenderOptions {
1479 front_matter: true,
1480 ..Default::default()
1481 };
1482 let md = render_graph(&graph, &opts);
1483
1484 assert!(md.starts_with("---\n"));
1485 assert!(md.contains("type: graph"));
1486 assert!(md.contains("paths: 1"));
1487 assert!(md.contains("external_refs: 1"));
1488 }
1489
1490 #[test]
1491 fn test_render_graph_with_meta_refs() {
1492 let graph = Graph {
1493 graph: GraphIdentity { id: "g1".into() },
1494 paths: vec![],
1495 meta: Some(GraphMeta {
1496 title: Some("Release".into()),
1497 refs: vec![Ref {
1498 rel: "milestone".into(),
1499 href: "issue://github/org/repo/milestone/5".into(),
1500 }],
1501 ..Default::default()
1502 }),
1503 };
1504 let md = render_graph(&graph, &RenderOptions::default());
1505
1506 assert!(md.contains("**milestone:**"));
1507 }
1508
1509 #[test]
1512 fn test_render_dispatches_step() {
1513 let step = make_step("s1", "human:alex", &[]);
1514 let doc = Document::Step(step);
1515 let md = render(&doc, &RenderOptions::default());
1516 assert!(md.contains("# s1"));
1517 }
1518
1519 #[test]
1520 fn test_render_dispatches_path() {
1521 let s1 = make_step("s1", "human:alex", &[]);
1522 let path = Path {
1523 path: PathIdentity {
1524 id: "p1".into(),
1525 base: None,
1526 head: "s1".into(),
1527 },
1528 steps: vec![s1],
1529 meta: None,
1530 };
1531 let doc = Document::Path(path);
1532 let md = render(&doc, &RenderOptions::default());
1533 assert!(md.contains("## Timeline"));
1534 }
1535
1536 #[test]
1537 fn test_render_dispatches_graph() {
1538 let graph = Graph {
1539 graph: GraphIdentity { id: "g1".into() },
1540 paths: vec![],
1541 meta: Some(GraphMeta {
1542 title: Some("My Graph".into()),
1543 ..Default::default()
1544 }),
1545 };
1546 let doc = Document::Graph(graph);
1547 let md = render(&doc, &RenderOptions::default());
1548 assert!(md.contains("# My Graph"));
1549 }
1550
1551 #[test]
1554 fn test_topo_sort_linear() {
1555 let s1 = make_step("s1", "human:alex", &[]);
1556 let s2 = make_step("s2", "agent:claude", &["s1"]);
1557 let s3 = make_step("s3", "human:alex", &["s2"]);
1558 let steps = vec![s3.clone(), s1.clone(), s2.clone()]; let sorted = topo_sort(&steps);
1560 let ids: Vec<&str> = sorted.iter().map(|s| s.step.id.as_str()).collect();
1561 assert_eq!(ids, vec!["s1", "s2", "s3"]);
1562 }
1563
1564 #[test]
1565 fn test_topo_sort_branching() {
1566 let s1 = make_step("s1", "human:alex", &[]);
1567 let s2a = make_step("s2a", "agent:claude", &["s1"]);
1568 let s2b = make_step("s2b", "agent:claude", &["s1"]);
1569 let s3 = make_step("s3", "human:alex", &["s2a", "s2b"]);
1570 let steps = vec![s1, s2a, s2b, s3];
1571 let sorted = topo_sort(&steps);
1572 let ids: Vec<&str> = sorted.iter().map(|s| s.step.id.as_str()).collect();
1573
1574 assert_eq!(ids[0], "s1");
1576 assert_eq!(ids[3], "s3");
1577 }
1578
1579 #[test]
1580 fn test_topo_sort_preserves_input_order_for_roots() {
1581 let s1 = make_step("s1", "human:alex", &[]);
1582 let s2 = make_step("s2", "human:bob", &[]);
1583 let steps = vec![s1, s2];
1584 let sorted = topo_sort(&steps);
1585 let ids: Vec<&str> = sorted.iter().map(|s| s.step.id.as_str()).collect();
1586 assert_eq!(ids, vec!["s1", "s2"]);
1587 }
1588
1589 #[test]
1592 fn test_count_diff_lines() {
1593 let diff = "@@ -1,3 +1,4 @@\n-old1\n-old2\n+new1\n+new2\n+new3\n context";
1594 let (add, del) = count_diff_lines(diff);
1595 assert_eq!(add, 3);
1596 assert_eq!(del, 2);
1597 }
1598
1599 #[test]
1600 fn test_count_diff_lines_ignores_triple_prefix() {
1601 let diff = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new";
1602 let (add, del) = count_diff_lines(diff);
1603 assert_eq!(add, 1);
1604 assert_eq!(del, 1);
1605 }
1606
1607 #[test]
1608 fn test_count_diff_lines_empty() {
1609 assert_eq!(count_diff_lines(""), (0, 0));
1610 }
1611
1612 #[test]
1615 fn test_render_structural_change_summary() {
1616 let mut step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z");
1617 step.change.insert(
1618 "src/main.rs".into(),
1619 toolpath::v1::ArtifactChange {
1620 raw: None,
1621 structural: Some(StructuralChange {
1622 change_type: "rename_function".into(),
1623 extra: Default::default(),
1624 }),
1625 },
1626 );
1627 let md = render_step(&step, &RenderOptions::default());
1628 assert!(md.contains("rename_function"));
1629 }
1630
1631 #[test]
1632 fn test_render_structural_change_full() {
1633 let mut extra = std::collections::HashMap::new();
1634 extra.insert("from".to_string(), serde_json::json!("foo"));
1635 extra.insert("to".to_string(), serde_json::json!("bar"));
1636 let mut step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z");
1637 step.change.insert(
1638 "src/main.rs".into(),
1639 toolpath::v1::ArtifactChange {
1640 raw: None,
1641 structural: Some(StructuralChange {
1642 change_type: "rename_function".into(),
1643 extra,
1644 }),
1645 },
1646 );
1647 let md = render_step(
1648 &step,
1649 &RenderOptions {
1650 detail: Detail::Full,
1651 ..Default::default()
1652 },
1653 );
1654 assert!(md.contains("Structural: `rename_function`"));
1655 }
1656
1657 #[test]
1660 fn test_render_path_with_actors() {
1661 let s1 = make_step("s1", "human:alex", &[]);
1662 let mut actors = std::collections::HashMap::new();
1663 actors.insert(
1664 "human:alex".into(),
1665 toolpath::v1::ActorDefinition {
1666 name: Some("Alex".into()),
1667 provider: None,
1668 model: None,
1669 identities: vec![],
1670 keys: vec![],
1671 },
1672 );
1673 actors.insert(
1674 "agent:claude-code".into(),
1675 toolpath::v1::ActorDefinition {
1676 name: Some("Claude Code".into()),
1677 provider: Some("Anthropic".into()),
1678 model: Some("claude-sonnet-4-20250514".into()),
1679 identities: vec![],
1680 keys: vec![],
1681 },
1682 );
1683 let path = Path {
1684 path: PathIdentity {
1685 id: "p1".into(),
1686 base: None,
1687 head: "s1".into(),
1688 },
1689 steps: vec![s1],
1690 meta: Some(PathMeta {
1691 actors: Some(actors),
1692 ..Default::default()
1693 }),
1694 };
1695 let md = render_path(&path, &RenderOptions::default());
1696
1697 assert!(md.contains("## Actors"));
1698 assert!(md.contains("Alex"));
1699 assert!(md.contains("Claude Code"));
1700 assert!(md.contains("Anthropic"));
1701 }
1702
1703 #[test]
1706 fn test_render_path_full_detail() {
1707 let s1 = make_step("s1", "human:alex", &[]);
1708 let path = Path {
1709 path: PathIdentity {
1710 id: "p1".into(),
1711 base: None,
1712 head: "s1".into(),
1713 },
1714 steps: vec![s1],
1715 meta: None,
1716 };
1717 let opts = RenderOptions {
1718 detail: Detail::Full,
1719 ..Default::default()
1720 };
1721 let md = render_path(&path, &opts);
1722
1723 assert!(md.contains("```diff"));
1724 assert!(md.contains("-old"));
1725 assert!(md.contains("+new"));
1726 }
1727
1728 #[test]
1731 fn test_render_path_no_title() {
1732 let s1 = make_step("s1", "human:alex", &[]);
1733 let path = Path {
1734 path: PathIdentity {
1735 id: "path-42".into(),
1736 base: None,
1737 head: "s1".into(),
1738 },
1739 steps: vec![s1],
1740 meta: None,
1741 };
1742 let md = render_path(&path, &RenderOptions::default());
1743 assert!(md.starts_with("# path-42"));
1744 }
1745
1746 #[test]
1747 fn test_render_step_no_changes() {
1748 let step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z");
1749 let md = render_step(&step, &RenderOptions::default());
1750 assert!(md.contains("# s1"));
1751 assert!(!md.contains("## Changes"));
1752 }
1753
1754 #[test]
1755 fn test_render_graph_empty_paths() {
1756 let graph = Graph {
1757 graph: GraphIdentity { id: "g1".into() },
1758 paths: vec![],
1759 meta: None,
1760 };
1761 let md = render_graph(&graph, &RenderOptions::default());
1762 assert!(md.contains("# g1"));
1763 }
1764
1765 fn make_review_comment_step(id: &str, actor: &str, artifact: &str, body: &str) -> Step {
1768 let mut extra = std::collections::HashMap::new();
1769 extra.insert("body".to_string(), serde_json::json!(body));
1770 let mut step = Step::new(id, actor, "2026-01-29T10:00:00Z");
1771 step.change.insert(
1772 artifact.to_string(),
1773 ArtifactChange {
1774 raw: Some("@@ -1,3 +1,4 @@\n fn example() {\n+ let x = 42;\n }".to_string()),
1775 structural: Some(StructuralChange {
1776 change_type: "review.comment".into(),
1777 extra,
1778 }),
1779 },
1780 );
1781 step
1782 }
1783
1784 fn make_review_decision_step(id: &str, actor: &str, state: &str, body: &str) -> Step {
1785 let mut extra = std::collections::HashMap::new();
1786 extra.insert("state".to_string(), serde_json::json!(state));
1787 let mut step = Step::new(id, actor, "2026-01-29T11:00:00Z");
1788 step.change.insert(
1789 "review://decision".to_string(),
1790 ArtifactChange {
1791 raw: if body.is_empty() {
1792 None
1793 } else {
1794 Some(body.to_string())
1795 },
1796 structural: Some(StructuralChange {
1797 change_type: "review.decision".into(),
1798 extra,
1799 }),
1800 },
1801 );
1802 step
1803 }
1804
1805 fn make_ci_step(id: &str, name: &str, conclusion: &str) -> Step {
1806 let mut extra = std::collections::HashMap::new();
1807 extra.insert("conclusion".to_string(), serde_json::json!(conclusion));
1808 extra.insert(
1809 "url".to_string(),
1810 serde_json::json!("https://github.com/acme/widgets/actions/runs/123"),
1811 );
1812 let mut step = Step::new(id, "ci:github-actions", "2026-01-29T12:00:00Z");
1813 step.change.insert(
1814 format!("ci://checks/{}", name),
1815 ArtifactChange {
1816 raw: None,
1817 structural: Some(StructuralChange {
1818 change_type: "ci.run".into(),
1819 extra,
1820 }),
1821 },
1822 );
1823 step
1824 }
1825
1826 #[test]
1827 fn test_render_review_comment_summary() {
1828 let step = make_review_comment_step(
1829 "s1",
1830 "human:bob",
1831 "review://src/main.rs#L42",
1832 "Consider using a constant here.",
1833 );
1834 let md = render_step(&step, &RenderOptions::default());
1835
1836 assert!(md.contains("src/main.rs:42"));
1838 assert!(md.contains("Consider using a constant here."));
1839 assert!(!md.contains("review://"));
1841 }
1842
1843 #[test]
1844 fn test_render_review_comment_full() {
1845 let step = make_review_comment_step(
1846 "s1",
1847 "human:bob",
1848 "review://src/main.rs#L42",
1849 "Consider using a constant here.",
1850 );
1851 let md = render_step(
1852 &step,
1853 &RenderOptions {
1854 detail: Detail::Full,
1855 ..Default::default()
1856 },
1857 );
1858
1859 assert!(md.contains("> Consider using a constant here."));
1861 assert!(md.contains("```diff"));
1863 assert!(md.contains("let x = 42"));
1864 }
1865
1866 #[test]
1867 fn test_render_review_decision_summary() {
1868 let step = make_review_decision_step("s1", "human:dave", "APPROVED", "LGTM!");
1869 let md = render_step(&step, &RenderOptions::default());
1870
1871 assert!(md.contains("[approved]"));
1872 assert!(md.contains("APPROVED"));
1873 assert!(md.contains("LGTM!"));
1874 }
1875
1876 #[test]
1877 fn test_render_ci_summary() {
1878 let step = make_ci_step("s1", "test", "success");
1879 let md = render_step(&step, &RenderOptions::default());
1880
1881 assert!(md.contains("test"));
1882 assert!(md.contains("[pass]"));
1883 assert!(md.contains("success"));
1884 assert!(!md.contains("ci://checks/"));
1886 }
1887
1888 #[test]
1889 fn test_render_ci_failure() {
1890 let step = make_ci_step("s1", "lint", "failure");
1891 let md = render_step(&step, &RenderOptions::default());
1892
1893 assert!(md.contains("lint"));
1894 assert!(md.contains("[fail]"));
1895 assert!(md.contains("failure"));
1896 }
1897
1898 #[test]
1899 fn test_render_ci_full_with_url() {
1900 let step = make_ci_step("s1", "test", "success");
1901 let md = render_step(
1902 &step,
1903 &RenderOptions {
1904 detail: Detail::Full,
1905 ..Default::default()
1906 },
1907 );
1908
1909 assert!(md.contains("details"));
1910 assert!(md.contains("actions/runs/123"));
1911 }
1912
1913 #[test]
1914 fn test_render_review_section() {
1915 let s1 = make_step("s1", "human:alice", &[]);
1916 let s2 = make_review_comment_step(
1917 "s2",
1918 "human:bob",
1919 "review://src/main.rs#L42",
1920 "Consider using a constant.",
1921 );
1922 let s3 = make_review_decision_step("s3", "human:dave", "APPROVED", "Ship it!");
1923 let mut s2 = s2;
1924 s2 = s2.with_parent("s1");
1925 let mut s3 = s3;
1926 s3 = s3.with_parent("s2");
1927 let path = Path {
1928 path: PathIdentity {
1929 id: "p1".into(),
1930 base: None,
1931 head: "s3".into(),
1932 },
1933 steps: vec![s1, s2, s3],
1934 meta: None,
1935 };
1936 let md = render_path(&path, &RenderOptions::default());
1937
1938 assert!(md.contains("## Review"));
1939 assert!(md.contains("APPROVED"));
1940 assert!(md.contains("Ship it!"));
1941 assert!(md.contains("### Inline comments"));
1942 assert!(md.contains("src/main.rs:42"));
1943 assert!(md.contains("Consider using a constant."));
1944 }
1945
1946 #[test]
1947 fn test_render_no_review_section_without_reviews() {
1948 let s1 = make_step("s1", "human:alex", &[]);
1949 let path = Path {
1950 path: PathIdentity {
1951 id: "p1".into(),
1952 base: None,
1953 head: "s1".into(),
1954 },
1955 steps: vec![s1],
1956 meta: None,
1957 };
1958 let md = render_path(&path, &RenderOptions::default());
1959
1960 assert!(!md.contains("## Review"));
1961 }
1962
1963 #[test]
1966 fn test_render_pr_identity() {
1967 let s1 = make_step("s1", "human:alice", &[]);
1968 let mut extra = std::collections::HashMap::new();
1969 let github = serde_json::json!({
1970 "number": 42,
1971 "author": "alice",
1972 "state": "open",
1973 "draft": false,
1974 "merged": false,
1975 "additions": 150,
1976 "deletions": 30,
1977 "changed_files": 5
1978 });
1979 extra.insert("github".to_string(), github);
1980 let path = Path {
1981 path: PathIdentity {
1982 id: "pr-42".into(),
1983 base: None,
1984 head: "s1".into(),
1985 },
1986 steps: vec![s1],
1987 meta: Some(PathMeta {
1988 title: Some("Add feature".into()),
1989 extra,
1990 ..Default::default()
1991 }),
1992 };
1993 let md = render_path(&path, &RenderOptions::default());
1994
1995 assert!(md.contains("**PR #42**"));
1996 assert!(md.contains("by alice"));
1997 assert!(md.contains("open"));
1998 assert!(md.contains("+150"));
1999 assert!(md.contains("\u{2212}30"));
2000 assert!(md.contains("5 files"));
2001 assert!(!md.contains("**Head:**"));
2003 }
2004
2005 #[test]
2006 fn test_render_no_pr_identity_without_github_meta() {
2007 let s1 = make_step("s1", "human:alex", &[]);
2008 let path = Path {
2009 path: PathIdentity {
2010 id: "p1".into(),
2011 base: None,
2012 head: "s1".into(),
2013 },
2014 steps: vec![s1],
2015 meta: None,
2016 };
2017 let md = render_path(&path, &RenderOptions::default());
2018
2019 assert!(md.contains("**Head:**"));
2021 assert!(!md.contains("**PR #"));
2022 }
2023
2024 #[test]
2027 fn test_friendly_artifact_name() {
2028 assert_eq!(
2029 friendly_artifact_name("review://src/main.rs#L42"),
2030 "src/main.rs:42"
2031 );
2032 assert_eq!(friendly_artifact_name("ci://checks/test"), "test");
2033 assert_eq!(friendly_artifact_name("review://decision"), "decision");
2034 assert_eq!(friendly_artifact_name("src/main.rs"), "src/main.rs");
2035 }
2036
2037 #[test]
2038 fn test_friendly_date_range_same_day() {
2039 let s1 = Step::new("s1", "human:alex", "2026-02-26T10:00:00Z");
2040 let s2 = Step::new("s2", "human:alex", "2026-02-26T14:00:00Z");
2041 assert_eq!(friendly_date_range(&[s1, s2]), "Feb 26, 2026");
2042 }
2043
2044 #[test]
2045 fn test_friendly_date_range_same_month() {
2046 let s1 = Step::new("s1", "human:alex", "2026-02-26T10:00:00Z");
2047 let s2 = Step::new("s2", "human:alex", "2026-02-27T14:00:00Z");
2048 assert_eq!(friendly_date_range(&[s1, s2]), "Feb 26\u{2013}27, 2026");
2049 }
2050
2051 #[test]
2052 fn test_friendly_date_range_different_months() {
2053 let s1 = Step::new("s1", "human:alex", "2026-02-26T10:00:00Z");
2054 let s2 = Step::new("s2", "human:alex", "2026-03-01T14:00:00Z");
2055 assert_eq!(
2056 friendly_date_range(&[s1, s2]),
2057 "Feb 26 \u{2013} Mar 1, 2026"
2058 );
2059 }
2060
2061 #[test]
2062 fn test_friendly_date_range_empty() {
2063 assert_eq!(friendly_date_range(&[]), "");
2064 }
2065
2066 #[test]
2067 fn test_truncate_str() {
2068 assert_eq!(truncate_str("hello", 10), "hello");
2069 assert_eq!(
2070 truncate_str("hello world this is long", 10),
2071 "hello worl..."
2072 );
2073 assert_eq!(truncate_str("line1\nline2", 20), "line1 line2");
2074 }
2075
2076 fn make_conversation_step(id: &str, actor: &str, body: &str) -> Step {
2079 let mut extra = std::collections::HashMap::new();
2080 extra.insert("body".to_string(), serde_json::json!(body));
2081 let mut step = Step::new(id, actor, "2026-01-29T15:00:00Z");
2082 step.change.insert(
2083 "review://conversation".to_string(),
2084 ArtifactChange {
2085 raw: None,
2086 structural: Some(StructuralChange {
2087 change_type: "review.conversation".into(),
2088 extra,
2089 }),
2090 },
2091 );
2092 step
2093 }
2094
2095 #[test]
2096 fn test_render_conversation_summary() {
2097 let step = make_conversation_step("s1", "human:carol", "Looks good overall!");
2098 let md = render_step(&step, &RenderOptions::default());
2099
2100 assert!(md.contains("conversation"));
2101 assert!(md.contains("Looks good overall!"));
2102 assert!(!md.contains("review://"));
2104 }
2105
2106 #[test]
2107 fn test_render_conversation_full() {
2108 let step = make_conversation_step("s1", "human:carol", "Looks good overall!");
2109 let md = render_step(
2110 &step,
2111 &RenderOptions {
2112 detail: Detail::Full,
2113 ..Default::default()
2114 },
2115 );
2116
2117 assert!(md.contains("> Looks good overall!"));
2118 assert!(!md.contains("review://"));
2119 }
2120
2121 #[test]
2122 fn test_review_section_includes_conversations() {
2123 let s1 = make_step("s1", "human:alice", &[]);
2124 let s2 = make_conversation_step("s2", "human:carol", "Looks good overall!");
2125 let s3 = make_review_decision_step("s3", "human:dave", "APPROVED", "Ship it!");
2126 let s2 = s2.with_parent("s1");
2127 let s3 = s3.with_parent("s2");
2128 let path = Path {
2129 path: PathIdentity {
2130 id: "p1".into(),
2131 base: None,
2132 head: "s3".into(),
2133 },
2134 steps: vec![s1, s2, s3],
2135 meta: None,
2136 };
2137 let md = render_path(&path, &RenderOptions::default());
2138
2139 assert!(md.contains("## Review"));
2140 assert!(md.contains("### Discussion"));
2141 assert!(md.contains("carol"));
2142 assert!(md.contains("Looks good overall!"));
2143 assert!(md.contains("APPROVED"));
2144 }
2145
2146 #[test]
2147 fn test_render_merged_pr() {
2148 let s1 = make_step("s1", "human:alice", &[]);
2149 let mut extra = std::collections::HashMap::new();
2150 let github = serde_json::json!({
2151 "number": 7,
2152 "author": "alice",
2153 "state": "closed",
2154 "draft": false,
2155 "merged": true,
2156 "additions": 42,
2157 "deletions": 10,
2158 "changed_files": 3
2159 });
2160 extra.insert("github".to_string(), github);
2161 let path = Path {
2162 path: PathIdentity {
2163 id: "pr-7".into(),
2164 base: None,
2165 head: "s1".into(),
2166 },
2167 steps: vec![s1],
2168 meta: Some(PathMeta {
2169 title: Some("Fix the thing".into()),
2170 extra,
2171 ..Default::default()
2172 }),
2173 };
2174 let md = render_path(&path, &RenderOptions::default());
2175
2176 assert!(md.contains("**PR #7**"));
2177 assert!(md.contains("by alice"));
2178 assert!(md.contains("merged"));
2180 assert!(!md.contains("closed"));
2181 }
2182
2183 #[test]
2184 fn test_catch_all_uses_friendly_name() {
2185 let mut step = Step::new("s1", "human:alex", "2026-01-29T10:00:00Z");
2187 step.change.insert(
2188 "review://some/path#L5".to_string(),
2189 ArtifactChange {
2190 raw: None,
2191 structural: Some(StructuralChange {
2192 change_type: "review.custom".into(),
2193 extra: Default::default(),
2194 }),
2195 },
2196 );
2197 let md = render_step(&step, &RenderOptions::default());
2198
2199 assert!(md.contains("some/path:5"));
2201 assert!(!md.contains("review://"));
2202 }
2203}