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