1use std::collections::BTreeMap;
2
3use chrono::Utc;
4use serde::Serialize;
5use serde_json::Value;
6use sha2::{Digest, Sha256};
7use toon::EncodeOptions;
8use toon::options::KeyFoldingMode;
9
10use crate::Result;
11use crate::cli::OutputFormat;
12use crate::model::Issue;
13
14#[derive(Debug, Clone, Serialize)]
15pub struct RobotEnvelope {
16 pub generated_at: String,
17 pub data_hash: String,
18 pub output_format: String,
19 pub version: String,
20}
21
22#[must_use]
23pub fn envelope(issues: &[Issue]) -> RobotEnvelope {
24 RobotEnvelope {
25 generated_at: Utc::now().to_rfc3339(),
26 data_hash: compute_data_hash(issues),
27 output_format: "json".to_string(),
28 version: format!("v{}", env!("CARGO_PKG_VERSION")),
29 }
30}
31
32#[must_use]
34pub fn envelope_empty() -> RobotEnvelope {
35 RobotEnvelope {
36 generated_at: Utc::now().to_rfc3339(),
37 data_hash: String::new(),
38 output_format: "json".to_string(),
39 version: format!("v{}", env!("CARGO_PKG_VERSION")),
40 }
41}
42
43#[must_use]
44pub fn compute_data_hash(issues: &[Issue]) -> String {
45 let mut stable = issues
46 .iter()
47 .map(|issue| {
48 (
49 issue.id.clone(),
50 issue.status.clone(),
51 issue.priority,
52 issue
53 .updated_at
54 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
55 .unwrap_or_default(),
56 )
57 })
58 .collect::<Vec<_>>();
59
60 stable.sort_by(|left, right| left.0.cmp(&right.0));
61
62 let mut hasher = Sha256::new();
63 for row in stable {
64 hasher.update(row.0);
65 hasher.update(b"\x1f");
66 hasher.update(row.1);
67 hasher.update(b"\x1f");
68 hasher.update(row.2.to_string());
69 hasher.update(b"\x1f");
70 hasher.update(row.3);
71 hasher.update("\n");
72 }
73
74 let digest = hasher.finalize();
75 format!("{digest:x}")[..16].to_string()
76}
77
78pub fn emit<T: Serialize>(format: OutputFormat, payload: &T) -> Result<()> {
79 let rendered = render_payload(format, payload)?;
80 print_output(&rendered.output);
81 Ok(())
82}
83
84pub fn emit_with_stats<T: Serialize>(
85 format: OutputFormat,
86 payload: &T,
87 show_stats: bool,
88) -> Result<()> {
89 let rendered = render_payload(format, payload)?;
90 print_output(&rendered.output);
91 if show_stats {
92 print_format_stats(&rendered.json_for_stats, rendered.toon_for_stats.as_deref());
93 }
94 Ok(())
95}
96
97struct RenderedPayload {
98 output: String,
99 json_for_stats: String,
100 toon_for_stats: Option<String>,
101}
102
103struct ToonEncodeOptions {
109 key_folding: Option<KeyFoldingMode>,
110 indent: Option<usize>,
111}
112
113fn resolve_toon_encode_options() -> ToonEncodeOptions {
114 let key_folding = std::env::var("TOON_KEY_FOLDING")
115 .ok()
116 .as_deref()
117 .map(str::trim)
118 .filter(|value| !value.is_empty())
119 .and_then(parse_toon_key_folding_mode);
120
121 let indent = std::env::var("TOON_INDENT")
122 .ok()
123 .map(|value| value.trim().to_string())
124 .filter(|value| !value.is_empty())
125 .and_then(|raw| {
126 raw.parse::<usize>().map_or_else(
127 |_| {
128 eprintln!(
129 "warning: ignoring invalid TOON_INDENT value {raw:?}; expected integer 0-16"
130 );
131 None
132 },
133 |indent| Some(indent.min(16)),
134 )
135 });
136
137 ToonEncodeOptions {
138 key_folding,
139 indent,
140 }
141}
142
143fn parse_toon_key_folding_mode(raw: &str) -> Option<KeyFoldingMode> {
144 match raw {
145 "off" => None,
146 "safe" => Some(KeyFoldingMode::Safe),
147 invalid => {
148 eprintln!(
149 "warning: ignoring invalid TOON_KEY_FOLDING value {invalid:?}; expected off|safe"
150 );
151 None
152 }
153 }
154}
155
156fn render_payload<T: Serialize>(format: OutputFormat, payload: &T) -> Result<RenderedPayload> {
157 let mut value = serde_json::to_value(payload)?;
158
159 match format {
160 OutputFormat::Json => {
161 set_top_level_output_format(&mut value, OutputFormat::Json);
162 let json_for_stats = serde_json::to_string(&value)?;
163 let output = encode_json(&value)?;
164 Ok(RenderedPayload {
165 output,
166 json_for_stats,
167 toon_for_stats: None,
168 })
169 }
170 OutputFormat::Toon => {
171 set_top_level_output_format(&mut value, OutputFormat::Toon);
172 let json_for_stats = serde_json::to_string(&value)?;
173 let output = encode_toon(&value);
174 Ok(RenderedPayload {
175 toon_for_stats: Some(output.clone()),
176 output,
177 json_for_stats,
178 })
179 }
180 }
181}
182
183fn encode_json(value: &Value) -> Result<String> {
184 if std::env::var("BV_PRETTY_JSON").is_ok_and(|value| value.trim() == "1") {
185 Ok(serde_json::to_string_pretty(value)?)
186 } else {
187 Ok(serde_json::to_string(value)?)
188 }
189}
190
191fn set_top_level_output_format(value: &mut Value, format: OutputFormat) {
192 if let Some(object) = value.as_object_mut()
193 && object.contains_key("output_format")
194 {
195 let label = match format {
196 OutputFormat::Json => "json",
197 OutputFormat::Toon => "toon",
198 };
199 object.insert(
200 "output_format".to_string(),
201 Value::String(label.to_string()),
202 );
203 }
204}
205
206fn print_output(output: &str) {
207 print!("{output}");
208 if !output.ends_with('\n') {
209 println!();
210 }
211}
212
213fn encode_toon(value: &Value) -> String {
214 let opts = resolve_toon_encode_options();
215 let options = Some(EncodeOptions {
216 indent: opts.indent,
217 delimiter: None,
218 key_folding: opts.key_folding,
219 flatten_depth: None,
220 replacer: None,
221 });
222 let mut toon_str = toon::encode(value.clone(), options);
223 let trimmed_len = toon_str.trim_end().len();
225 toon_str.truncate(trimmed_len);
226 toon_str.push('\n');
227 toon_str
228}
229
230#[must_use]
231pub fn default_field_descriptions() -> BTreeMap<&'static str, &'static str> {
232 BTreeMap::from([
233 ("score", "Composite impact score (0..1)"),
234 ("impact_score", "Legacy alias of score for compatibility"),
235 (
236 "confidence",
237 "Heuristic confidence for recommendation quality (0..1)",
238 ),
239 (
240 "unblocks",
241 "Count of downstream issues immediately unblocked",
242 ),
243 (
244 "claim_command",
245 "Suggested br command to claim/start the issue",
246 ),
247 ])
248}
249
250#[derive(Debug, Serialize)]
255struct CmdDoc {
256 flag: &'static str,
257 description: &'static str,
258 #[serde(skip_serializing_if = "Vec::is_empty")]
259 key_fields: Vec<&'static str>,
260 #[serde(skip_serializing_if = "Vec::is_empty")]
261 params: Vec<&'static str>,
262 needs_issues: bool,
263}
264
265#[cfg(test)]
266fn implemented_robot_command_names() -> &'static [&'static str] {
267 &[
268 "robot-triage",
269 "robot-next",
270 "robot-overview",
271 "robot-plan",
272 "robot-insights",
273 "robot-priority",
274 "robot-triage-by-track",
275 "robot-triage-by-label",
276 "robot-alerts",
277 "robot-suggest",
278 "robot-schema",
279 "robot-docs",
280 "robot-history",
281 "robot-diff",
282 "robot-graph",
283 "robot-forecast",
284 "robot-capacity",
285 "robot-burndown",
286 "robot-sprint-list",
287 "robot-sprint-show",
288 "robot-metrics",
289 "robot-label-health",
290 "robot-label-flow",
291 "robot-label-attention",
292 "robot-explain-correlation",
293 "robot-confirm-correlation",
294 "robot-reject-correlation",
295 "robot-correlation-stats",
296 "robot-orphans",
297 "robot-file-beads",
298 "robot-file-hotspots",
299 "robot-impact",
300 "robot-file-relations",
301 "robot-related",
302 "robot-blocker-chain",
303 "robot-impact-network",
304 "robot-causality",
305 "robot-drift",
306 "robot-search",
307 "robot-recipes",
308 "robot-economics",
309 "robot-delivery",
310 ]
311}
312
313fn robot_command_docs() -> BTreeMap<&'static str, CmdDoc> {
314 BTreeMap::from([
315 (
316 "robot-triage",
317 CmdDoc {
318 flag: "--robot-triage",
319 description: "Unified triage: top picks, recommendations, quick wins, blockers, project health, velocity.",
320 key_fields: vec![
321 "triage.quick_ref.top_picks",
322 "triage.recommendations",
323 "triage.quick_wins",
324 "triage.blockers_to_clear",
325 "triage.project_health",
326 ],
327 params: vec![],
328 needs_issues: true,
329 },
330 ),
331 (
332 "robot-next",
333 CmdDoc {
334 flag: "--robot-next",
335 description: "Single top recommendation with claim/show commands.",
336 key_fields: vec![
337 "id",
338 "title",
339 "score",
340 "reasons",
341 "unblocks",
342 "claim_command",
343 "show_command",
344 ],
345 params: vec![],
346 needs_issues: true,
347 },
348 ),
349 (
350 "robot-overview",
351 CmdDoc {
352 flag: "--robot-overview",
353 description: "Compact project orientation surface: counts, next action, blocker signal, diverse work fronts, and quick commands.",
354 key_fields: vec![
355 "summary",
356 "top_pick",
357 "top_blocker",
358 "top_labels",
359 "fronts",
360 "commands",
361 ],
362 params: vec![],
363 needs_issues: true,
364 },
365 ),
366 (
367 "robot-plan",
368 CmdDoc {
369 flag: "--robot-plan",
370 description: "Dependency-respecting execution plan with parallel tracks.",
371 key_fields: vec!["tracks", "items", "unblocks", "summary"],
372 params: vec![],
373 needs_issues: true,
374 },
375 ),
376 (
377 "robot-insights",
378 CmdDoc {
379 flag: "--robot-insights",
380 description: "Deep graph analysis: PageRank, betweenness, HITS, eigenvector, k-core, cycle detection.",
381 key_fields: vec![
382 "pagerank",
383 "betweenness",
384 "hits",
385 "eigenvector",
386 "k_core",
387 "cycles",
388 ],
389 params: vec![],
390 needs_issues: true,
391 },
392 ),
393 (
394 "robot-priority",
395 CmdDoc {
396 flag: "--robot-priority",
397 description: "Priority misalignment detection: items whose graph importance differs from assigned priority.",
398 key_fields: vec!["misalignments", "suggestions"],
399 params: vec![],
400 needs_issues: true,
401 },
402 ),
403 (
404 "robot-triage-by-track",
405 CmdDoc {
406 flag: "--robot-triage-by-track",
407 description: "Triage grouped by independent parallel execution tracks.",
408 key_fields: vec!["tracks[].track_id", "tracks[].top_pick", "tracks[].items"],
409 params: vec![],
410 needs_issues: true,
411 },
412 ),
413 (
414 "robot-triage-by-label",
415 CmdDoc {
416 flag: "--robot-triage-by-label",
417 description: "Triage grouped by label for area-focused agents.",
418 key_fields: vec!["labels[].label", "labels[].top_pick", "labels[].items"],
419 params: vec![],
420 needs_issues: true,
421 },
422 ),
423 (
424 "robot-alerts",
425 CmdDoc {
426 flag: "--robot-alerts",
427 description: "Stale issues, blocking cascades, priority mismatches.",
428 key_fields: vec!["alerts", "severity", "affected_issues"],
429 params: vec![
430 "--severity info|warning|critical",
431 "--alert-type <type>",
432 "--alert-label <label>",
433 ],
434 needs_issues: true,
435 },
436 ),
437 (
438 "robot-suggest",
439 CmdDoc {
440 flag: "--robot-suggest",
441 description: "Smart suggestions: potential duplicates, missing dependencies, label assignments, cycle warnings.",
442 key_fields: vec!["suggestions", "type", "confidence"],
443 params: vec![
444 "--suggest-type duplicate|dependency|label|cycle",
445 "--suggest-confidence 0.0-1.0",
446 "--suggest-bead <id>",
447 ],
448 needs_issues: true,
449 },
450 ),
451 (
452 "robot-schema",
453 CmdDoc {
454 flag: "--robot-schema",
455 description: "JSON Schema definitions for all robot command outputs.",
456 key_fields: vec!["schema_version", "envelope", "commands"],
457 params: vec!["--schema-command <cmd>"],
458 needs_issues: false,
459 },
460 ),
461 (
462 "robot-docs",
463 CmdDoc {
464 flag: "--robot-docs <topic>",
465 description: "Machine-readable JSON documentation. Topics: guide, commands, examples, env, exit-codes, all.",
466 key_fields: vec![],
467 params: vec![],
468 needs_issues: false,
469 },
470 ),
471 (
472 "robot-history",
473 CmdDoc {
474 flag: "--robot-history",
475 description: "Bead-to-commit correlations from git history.",
476 key_fields: vec!["correlations", "confidence", "commit_sha", "bead_id"],
477 params: vec![
478 "--bead-history <id>",
479 "--history-since <date>",
480 "--history-limit <n>",
481 "--min-confidence 0.0-1.0",
482 ],
483 needs_issues: true,
484 },
485 ),
486 (
487 "robot-diff",
488 CmdDoc {
489 flag: "--robot-diff",
490 description: "Changes since a historical point (commit, branch, tag, or date).",
491 key_fields: vec![],
492 params: vec!["--diff-since <ref>"],
493 needs_issues: true,
494 },
495 ),
496 (
497 "robot-graph",
498 CmdDoc {
499 flag: "--robot-graph",
500 description: "Dependency graph export in JSON, DOT, or Mermaid format.",
501 key_fields: vec![],
502 params: vec![
503 "--graph-format json|dot|mermaid",
504 "--graph-root <id>",
505 "--graph-depth <n>",
506 ],
507 needs_issues: true,
508 },
509 ),
510 (
511 "robot-forecast",
512 CmdDoc {
513 flag: "--robot-forecast <id|all>",
514 description: "ETA predictions for bead completion.",
515 key_fields: vec![],
516 params: vec![
517 "--forecast-label <label>",
518 "--forecast-sprint <id>",
519 "--forecast-agents <n>",
520 ],
521 needs_issues: true,
522 },
523 ),
524 (
525 "robot-capacity",
526 CmdDoc {
527 flag: "--robot-capacity",
528 description: "Capacity simulation and completion projections.",
529 key_fields: vec![],
530 params: vec!["--agents <n>", "--capacity-label <label>"],
531 needs_issues: true,
532 },
533 ),
534 (
535 "robot-burndown",
536 CmdDoc {
537 flag: "--robot-burndown <sprint|current>",
538 description: "Sprint burndown data.",
539 key_fields: vec![],
540 params: vec![],
541 needs_issues: true,
542 },
543 ),
544 (
545 "robot-sprint-list",
546 CmdDoc {
547 flag: "--robot-sprint-list",
548 description: "List all discovered sprints.",
549 key_fields: vec!["sprint_count", "sprints"],
550 params: vec![],
551 needs_issues: true,
552 },
553 ),
554 (
555 "robot-sprint-show",
556 CmdDoc {
557 flag: "--robot-sprint-show <id>",
558 description: "Show a single sprint payload.",
559 key_fields: vec!["sprint"],
560 params: vec![],
561 needs_issues: true,
562 },
563 ),
564 (
565 "robot-metrics",
566 CmdDoc {
567 flag: "--robot-metrics",
568 description: "Emit timing, cache, and memory telemetry.",
569 key_fields: vec!["timing", "cache", "memory"],
570 params: vec![],
571 needs_issues: true,
572 },
573 ),
574 (
575 "robot-label-health",
576 CmdDoc {
577 flag: "--robot-label-health",
578 description: "Per-label health, staleness, and blocked-work summary.",
579 key_fields: vec!["analysis_config", "results"],
580 params: vec![],
581 needs_issues: true,
582 },
583 ),
584 (
585 "robot-label-flow",
586 CmdDoc {
587 flag: "--robot-label-flow",
588 description: "Cross-label dependency flow and bottlenecks.",
589 key_fields: vec!["analysis_config", "flow"],
590 params: vec![],
591 needs_issues: true,
592 },
593 ),
594 (
595 "robot-label-attention",
596 CmdDoc {
597 flag: "--robot-label-attention",
598 description: "Attention-ranked labels using graph and freshness signals.",
599 key_fields: vec!["limit", "labels", "total_labels"],
600 params: vec!["--attention-limit <n>"],
601 needs_issues: true,
602 },
603 ),
604 (
605 "robot-explain-correlation",
606 CmdDoc {
607 flag: "--robot-explain-correlation <sha:bead>",
608 description: "Explain a git-history correlation candidate.",
609 key_fields: vec!["explanation"],
610 params: vec!["--correlation-by <actor>", "--correlation-reason <text>"],
611 needs_issues: true,
612 },
613 ),
614 (
615 "robot-confirm-correlation",
616 CmdDoc {
617 flag: "--robot-confirm-correlation <sha:bead>",
618 description: "Persist positive feedback for a history correlation candidate.",
619 key_fields: vec!["status", "commit", "bead", "by", "reason", "orig_conf"],
620 params: vec!["--correlation-by <actor>", "--correlation-reason <text>"],
621 needs_issues: true,
622 },
623 ),
624 (
625 "robot-reject-correlation",
626 CmdDoc {
627 flag: "--robot-reject-correlation <sha:bead>",
628 description: "Persist rejection feedback for a history correlation candidate.",
629 key_fields: vec!["status", "commit", "bead", "by", "reason", "orig_conf"],
630 params: vec!["--correlation-by <actor>", "--correlation-reason <text>"],
631 needs_issues: true,
632 },
633 ),
634 (
635 "robot-correlation-stats",
636 CmdDoc {
637 flag: "--robot-correlation-stats",
638 description: "Summarize stored correlation feedback.",
639 key_fields: vec!["total_feedback", "confirmed", "rejected", "accuracy_rate"],
640 params: vec![],
641 needs_issues: true,
642 },
643 ),
644 (
645 "robot-orphans",
646 CmdDoc {
647 flag: "--robot-orphans",
648 description: "Detect high-signal repository files that are not covered by bead history.",
649 key_fields: vec!["stats", "candidates"],
650 params: vec!["--orphans-min-score <n>"],
651 needs_issues: true,
652 },
653 ),
654 (
655 "robot-file-beads",
656 CmdDoc {
657 flag: "--robot-file-beads <path>",
658 description: "Look up beads correlated with a file path.",
659 key_fields: vec!["file_path", "total_beads", "open_beads", "closed_beads"],
660 params: vec!["--file-beads-limit <n>"],
661 needs_issues: true,
662 },
663 ),
664 (
665 "robot-file-hotspots",
666 CmdDoc {
667 flag: "--robot-file-hotspots",
668 description: "Rank file hotspots from bead-history evidence.",
669 key_fields: vec!["hotspots", "stats"],
670 params: vec!["--hotspots-limit <n>"],
671 needs_issues: true,
672 },
673 ),
674 (
675 "robot-impact",
676 CmdDoc {
677 flag: "--robot-impact <path[,path...]>",
678 description: "Estimate issue impact for one or more file paths.",
679 key_fields: vec![
680 "risk_level",
681 "risk_score",
682 "summary",
683 "files",
684 "affected_beads",
685 ],
686 params: vec![],
687 needs_issues: true,
688 },
689 ),
690 (
691 "robot-file-relations",
692 CmdDoc {
693 flag: "--robot-file-relations <path>",
694 description: "Find related files using shared bead-history evidence.",
695 key_fields: vec!["source_file", "related_files", "total_commits_for_source"],
696 params: vec!["--relations-threshold <n>", "--relations-limit <n>"],
697 needs_issues: true,
698 },
699 ),
700 (
701 "robot-related",
702 CmdDoc {
703 flag: "--robot-related <bead-id>",
704 description: "Find related work from file and history overlap.",
705 key_fields: vec!["source_bead", "related"],
706 params: vec![
707 "--related-min-relevance <n>",
708 "--related-max-results <n>",
709 "--related-include-closed",
710 ],
711 needs_issues: true,
712 },
713 ),
714 (
715 "robot-blocker-chain",
716 CmdDoc {
717 flag: "--robot-blocker-chain <bead-id>",
718 description: "Show upstream blockers for a target bead.",
719 key_fields: vec![
720 "target_id",
721 "chain_length",
722 "is_blocked",
723 "has_cycle",
724 "root_blockers",
725 ],
726 params: vec![],
727 needs_issues: true,
728 },
729 ),
730 (
731 "robot-impact-network",
732 CmdDoc {
733 flag: "--robot-impact-network <bead-id>",
734 description: "Build a causal impact network around a target bead.",
735 key_fields: vec!["bead_id", "depth", "network", "top_connected"],
736 params: vec!["--network-depth <n>"],
737 needs_issues: true,
738 },
739 ),
740 (
741 "robot-causality",
742 CmdDoc {
743 flag: "--robot-causality <bead-id>",
744 description: "Build a causality chain using graph and history evidence.",
745 key_fields: vec!["chain", "insights"],
746 params: vec![],
747 needs_issues: true,
748 },
749 ),
750 (
751 "robot-drift",
752 CmdDoc {
753 flag: "--robot-drift",
754 description: "Compare current state to a saved baseline and emit structured drift alerts.",
755 key_fields: vec!["summary", "alerts", "baseline"],
756 params: vec![],
757 needs_issues: true,
758 },
759 ),
760 (
761 "robot-search",
762 CmdDoc {
763 flag: "--robot-search",
764 description: "Run text or hybrid ranking over beads.",
765 key_fields: vec!["query", "limit", "mode", "results"],
766 params: vec![
767 "--search <query>",
768 "--search-mode text|hybrid",
769 "--search-preset <name>",
770 "--search-weights <json>",
771 "--search-limit <n>",
772 ],
773 needs_issues: true,
774 },
775 ),
776 (
777 "robot-recipes",
778 CmdDoc {
779 flag: "--robot-recipes",
780 description: "List named pre-filter recipes used by triage and scripting flows.",
781 key_fields: vec!["recipes"],
782 params: vec![],
783 needs_issues: false,
784 },
785 ),
786 (
787 "robot-economics",
788 CmdDoc {
789 flag: "--robot-economics",
790 description: "Operating-cost projection: burn rate, throughput, cost-to-complete, cost-of-delay. Pure arithmetic over existing analyzer state plus a small opt-in overlay.",
791 key_fields: vec![
792 "schema_version",
793 "overlay_hash",
794 "inputs.hourly_rate",
795 "inputs.estimate_coverage_pct",
796 "projections.burn_rate_per_day",
797 "projections.cost_to_complete",
798 "projections.cost_of_delay",
799 "guards",
800 ],
801 params: vec![
802 "--economics-overlay <path.json>",
803 "--insight-limit <n>",
804 "(env) BVR_ECONOMICS_OVERLAY",
805 ],
806 needs_issues: true,
807 },
808 ),
809 (
810 "robot-delivery",
811 CmdDoc {
812 flag: "--robot-delivery",
813 description: "Delivery posture: Reinertsen flow_distribution (Risk>Debt>Defects>Features), urgency_profile (Expedite>Fixed-Date>Intangible>Standard), and milestone_pressure. Classification only; no overlay required.",
814 key_fields: vec![
815 "schema_version",
816 "flow_distribution",
817 "urgency_profile",
818 "milestone_pressure",
819 ],
820 params: vec!["--insight-limit <n>"],
821 needs_issues: true,
822 },
823 ),
824 ])
825}
826
827#[must_use]
828pub fn generate_robot_docs(topic: &str) -> Value {
829 let now = Utc::now().to_rfc3339();
830 let version = env!("CARGO_PKG_VERSION");
831
832 let mut result = serde_json::json!({
833 "generated_at": now,
834 "output_format": "json",
835 "version": version,
836 "topic": topic,
837 });
838
839 let guide = serde_json::json!({
840 "description": "bvr (Beads Viewer Rust) provides structural analysis of the beads issue tracker DAG. It is the primary interface for AI agents to understand project state, plan work, and discover high-impact tasks.",
841 "quickstart": [
842 "bvr --robot-triage # Full triage with recommendations",
843 "bvr --robot-next # Single top pick for immediate work",
844 "bvr --robot-overview # Compact orientation snapshot for fast agent loops",
845 "bvr --robot-plan # Dependency-respecting execution plan",
846 "bvr --robot-insights # Deep graph analysis (PageRank, betweenness, etc.)",
847 "bvr --robot-triage-by-track # Parallel work streams for multi-agent coordination",
848 "bvr --robot-schema # JSON Schema definitions for all commands",
849 ],
850 "data_source": ".beads/beads.jsonl by default (compat: issues.jsonl, beads.base.jsonl) plus git history correlations",
851 "output_modes": {
852 "json": "Default structured output",
853 "toon": "Token-optimized notation (saves ~30-50% tokens)",
854 },
855 });
856
857 let commands =
858 serde_json::to_value(robot_command_docs()).unwrap_or_else(|_| serde_json::json!({}));
859
860 let examples = serde_json::json!([
861 {"description": "Get top 3 picks for immediate work", "command": "bvr --robot-triage | jq '.triage.quick_ref.top_picks[:3]'"},
862 {"description": "Claim the top recommendation", "command": "bvr --robot-next | jq -r '.claim_command' | sh"},
863 {"description": "Find high-impact blockers to clear", "command": "bvr --robot-triage | jq '.triage.blockers_to_clear | map(.id)'"},
864 {"description": "Get bug-only recommendations", "command": "bvr --robot-triage | jq '.triage.recommendations[] | select(.type == \"bug\")'"},
865 {"description": "Multi-agent: top pick per parallel track", "command": "bvr --robot-triage-by-track | jq '.triage.recommendations_by_track[].top_pick'"},
866 {"description": "Get TOON output (saves tokens)", "command": "bvr --robot-triage --format toon"},
867 {"description": "Use env for default format", "command": "BV_OUTPUT_FORMAT=toon bvr --robot-triage"},
868 {"description": "Show token savings estimate", "command": "bvr --robot-triage --format toon --stats"},
869 ]);
870
871 let env_vars = serde_json::json!({
872 "BV_OUTPUT_FORMAT": "Default output format: json or toon (overridden by --format)",
873 "TOON_DEFAULT_FORMAT": "Fallback format if BV_OUTPUT_FORMAT not set",
874 "TOON_STATS": "Set to 1 to show JSON vs TOON token estimates on stderr",
875 "TOON_KEY_FOLDING": "TOON key folding mode",
876 "TOON_INDENT": "TOON indentation level (0-16)",
877 "BV_PRETTY_JSON": "Set to 1 for indented JSON output",
878 "BV_ROBOT": "Set to 1 to force robot mode (clean stdout)",
879 "BV_SEARCH_MODE": "Search mode: text or hybrid",
880 "BV_SEARCH_PRESET": "Hybrid search preset name",
881 });
882
883 let exit_codes = serde_json::json!({
884 "0": "Success",
885 "1": "Error (general failure, drift critical)",
886 "2": "Invalid arguments or drift warning",
887 });
888
889 match topic {
890 "guide" => {
891 result["guide"] = guide;
892 }
893 "commands" => {
894 result["commands"] = commands;
895 }
896 "examples" => {
897 result["examples"] = examples;
898 }
899 "env" => {
900 result["environment_variables"] = env_vars;
901 }
902 "exit-codes" => {
903 result["exit_codes"] = exit_codes;
904 }
905 "all" => {
906 result["guide"] = guide;
907 result["commands"] = commands;
908 result["examples"] = examples;
909 result["environment_variables"] = env_vars;
910 result["exit_codes"] = exit_codes;
911 }
912 _ => {
913 result["error"] = Value::String(format!("Unknown topic: {topic}"));
914 result["available_topics"] =
915 serde_json::json!(["guide", "commands", "examples", "env", "exit-codes", "all"]);
916 }
917 }
918
919 result
920}
921
922#[derive(Debug, Serialize)]
927pub struct RobotSchemas {
928 pub schema_version: String,
929 pub generated_at: String,
930 pub envelope: Value,
931 pub commands: BTreeMap<String, Value>,
932}
933
934fn schema_prop(type_str: &str) -> Value {
935 serde_json::json!({"type": type_str})
936}
937
938fn schema_prop_dt() -> Value {
939 serde_json::json!({"type": "string", "format": "date-time"})
940}
941
942fn simple_command_schema(title: &str, description: &str, properties: Value) -> Value {
943 serde_json::json!({
944 "$schema": "https://json-schema.org/draft/2020-12/schema",
945 "title": title,
946 "description": description,
947 "type": "object",
948 "properties": properties,
949 })
950}
951
952fn versioned_properties(mut properties: Value) -> Value {
953 let Value::Object(ref mut map) = properties else {
954 return properties;
955 };
956 map.insert("output_format".to_string(), schema_prop("string"));
957 map.insert("version".to_string(), schema_prop("string"));
958 properties
959}
960
961fn versioned_simple_command_schema(title: &str, description: &str, properties: Value) -> Value {
962 simple_command_schema(title, description, versioned_properties(properties))
963}
964
965#[must_use]
966pub fn generate_robot_schemas() -> RobotSchemas {
967 let now = Utc::now().to_rfc3339();
968
969 let envelope = serde_json::json!({
970 "type": "object",
971 "properties": {
972 "generated_at": {
973 "type": "string",
974 "format": "date-time",
975 "description": "ISO 8601 timestamp when output was generated",
976 },
977 "data_hash": {
978 "type": "string",
979 "description": "Fingerprint of source beads.jsonl for cache validation",
980 },
981 "output_format": {
982 "type": "string",
983 "enum": ["json", "toon"],
984 "description": "Output format used (json or toon)",
985 },
986 "version": {
987 "type": "string",
988 "description": "bvr version that generated this output",
989 },
990 },
991 "required": ["generated_at", "data_hash"],
992 });
993
994 let mut commands = BTreeMap::new();
995
996 commands.insert("robot-triage".to_string(), serde_json::json!({
997 "$schema": "https://json-schema.org/draft/2020-12/schema",
998 "title": "Robot Triage Output",
999 "description": "Unified triage recommendations with quick picks, blockers, and project health",
1000 "type": "object",
1001 "properties": {
1002 "generated_at": schema_prop_dt(),
1003 "data_hash": schema_prop("string"),
1004 "triage": {
1005 "type": "object",
1006 "properties": {
1007 "meta": {
1008 "type": "object",
1009 "properties": {
1010 "version": schema_prop("string"),
1011 "generated_at": schema_prop("string"),
1012 "phase2_ready": schema_prop("boolean"),
1013 "issue_count": schema_prop("integer"),
1014 }
1015 },
1016 "quick_ref": {
1017 "type": "object",
1018 "properties": {
1019 "open_count": schema_prop("integer"),
1020 "actionable_count": schema_prop("integer"),
1021 "blocked_count": schema_prop("integer"),
1022 "in_progress_count": schema_prop("integer"),
1023 "top_picks": {
1024 "type": "array",
1025 "items": {"$ref": "#/$defs/recommendation"}
1026 }
1027 }
1028 },
1029 "recommendations": {"type": "array", "items": {"$ref": "#/$defs/recommendation"}},
1030 "quick_wins": {"type": "array"},
1031 "blockers_to_clear": {"type": "array"},
1032 "project_health": {"type": "object"},
1033 "commands": {"type": "object"},
1034 }
1035 },
1036 "usage_hints": {"type": "array", "items": schema_prop("string")},
1037 },
1038 "$defs": {
1039 "recommendation": {
1040 "type": "object",
1041 "properties": {
1042 "id": schema_prop("string"),
1043 "title": schema_prop("string"),
1044 "type": schema_prop("string"),
1045 "status": schema_prop("string"),
1046 "priority": schema_prop("integer"),
1047 "labels": {"type": "array", "items": schema_prop("string")},
1048 "score": schema_prop("number"),
1049 "impact_score": schema_prop("number"),
1050 "confidence": schema_prop("number"),
1051 "action": schema_prop("string"),
1052 "reasons": {"type": "array", "items": schema_prop("string")},
1053 "unblocks": schema_prop("integer"),
1054 "unblocks_ids": {"type": "array", "items": schema_prop("string")},
1055 "blocked_by": {"type": "array", "items": schema_prop("string")},
1056 "assignee": schema_prop("string"),
1057 "claim_command": schema_prop("string"),
1058 "show_command": schema_prop("string"),
1059 },
1060 "required": ["id", "title", "score", "action"],
1061 }
1062 }
1063 }));
1064
1065 commands.insert(
1066 "robot-next".to_string(),
1067 serde_json::json!({
1068 "$schema": "https://json-schema.org/draft/2020-12/schema",
1069 "title": "Robot Next Output",
1070 "description": "Single top pick recommendation with claim command",
1071 "type": "object",
1072 "properties": {
1073 "generated_at": schema_prop_dt(),
1074 "data_hash": schema_prop("string"),
1075 "id": schema_prop("string"),
1076 "title": schema_prop("string"),
1077 "score": schema_prop("number"),
1078 "reasons": {"type": "array", "items": schema_prop("string")},
1079 "unblocks": schema_prop("integer"),
1080 "claim_command": schema_prop("string"),
1081 "show_command": schema_prop("string"),
1082 },
1083 "required": ["generated_at", "data_hash", "id", "title", "score"],
1084 }),
1085 );
1086
1087 commands.insert(
1088 "robot-overview".to_string(),
1089 versioned_simple_command_schema(
1090 "Robot Overview Output",
1091 "Compact orientation payload with headline counts, top action, blocker signal, labels, and suggested next commands.",
1092 serde_json::json!({
1093 "generated_at": schema_prop_dt(),
1094 "data_hash": schema_prop("string"),
1095 "summary": {
1096 "type": "object",
1097 "properties": {
1098 "open_issues": schema_prop("integer"),
1099 "actionable_issues": schema_prop("integer"),
1100 "blocked_issues": schema_prop("integer"),
1101 "in_progress_issues": schema_prop("integer"),
1102 "closed_issues": schema_prop("integer"),
1103 "cycle_count": schema_prop("integer"),
1104 },
1105 },
1106 "top_pick": {
1107 "type": "object",
1108 "properties": {
1109 "id": schema_prop("string"),
1110 "title": schema_prop("string"),
1111 "score": schema_prop("number"),
1112 "reasons": {"type": "array", "items": schema_prop("string")},
1113 "claim_command": schema_prop("string"),
1114 },
1115 },
1116 "top_blocker": {
1117 "type": "object",
1118 "properties": {
1119 "id": schema_prop("string"),
1120 "title": schema_prop("string"),
1121 "unblocks": schema_prop("integer"),
1122 "show_command": schema_prop("string"),
1123 },
1124 },
1125 "top_labels": {
1126 "type": "array",
1127 "items": {
1128 "type": "object",
1129 "properties": {
1130 "label": schema_prop("string"),
1131 "open_issues": schema_prop("integer"),
1132 },
1133 },
1134 },
1135 "fronts": {
1136 "type": "array",
1137 "description": "Diverse work fronts grouped by label, each with a representative pick (max 8)",
1138 "items": {
1139 "type": "object",
1140 "properties": {
1141 "label": schema_prop("string"),
1142 "open_count": schema_prop("integer"),
1143 "representative": {
1144 "type": "object",
1145 "properties": {
1146 "id": schema_prop("string"),
1147 "title": schema_prop("string"),
1148 "score": schema_prop("number"),
1149 "reasons": {"type": "array", "items": schema_prop("string")},
1150 "claim_command": schema_prop("string"),
1151 },
1152 },
1153 },
1154 },
1155 },
1156 "commands": {
1157 "type": "object",
1158 "properties": {
1159 "next": schema_prop("string"),
1160 "triage": schema_prop("string"),
1161 "plan": schema_prop("string"),
1162 "history": schema_prop("string"),
1163 },
1164 },
1165 "usage_hints": {"type": "array", "items": schema_prop("string")},
1166 }),
1167 ),
1168 );
1169
1170 commands.insert(
1171 "robot-plan".to_string(),
1172 serde_json::json!({
1173 "$schema": "https://json-schema.org/draft/2020-12/schema",
1174 "title": "Robot Plan Output",
1175 "description": "Dependency-respecting execution plan with parallel tracks",
1176 "type": "object",
1177 "properties": {
1178 "generated_at": schema_prop_dt(),
1179 "data_hash": schema_prop("string"),
1180 "plan": {
1181 "type": "object",
1182 "properties": {
1183 "total_actionable": schema_prop("integer"),
1184 "total_blocked": schema_prop("integer"),
1185 "tracks": {
1186 "type": "array",
1187 "items": {
1188 "type": "object",
1189 "properties": {
1190 "track_id": schema_prop("string"),
1191 "items": {"type": "array"},
1192 "reason": schema_prop("string"),
1193 }
1194 }
1195 },
1196 "summary": {
1197 "type": "object",
1198 "properties": {
1199 "track_count": schema_prop("integer"),
1200 "actionable_count": schema_prop("integer"),
1201 "unblocks_count": schema_prop("integer"),
1202 "highest_impact": schema_prop("string"),
1203 "impact_reason": schema_prop("string"),
1204 }
1205 },
1206 }
1207 },
1208 "status": {"type": "object"},
1209 "usage_hints": {"type": "array"},
1210 },
1211 }),
1212 );
1213
1214 commands.insert("robot-insights".to_string(), serde_json::json!({
1215 "$schema": "https://json-schema.org/draft/2020-12/schema",
1216 "title": "Robot Insights Output",
1217 "description": "Full graph analysis metrics including PageRank, betweenness, HITS, cycles",
1218 "type": "object",
1219 "properties": {
1220 "generated_at": schema_prop_dt(),
1221 "data_hash": schema_prop("string"),
1222 "Stats": {"type": "object"},
1223 "Cycles": {"type": "array"},
1224 "Keystones": {"type": "array"},
1225 "Bottlenecks": {"type": "array"},
1226 "Influencers": {"type": "array"},
1227 "Hubs": {"type": "array"},
1228 "Authorities": {"type": "array"},
1229 "Orphans": {"type": "array"},
1230 "Cores": {"type": "object"},
1231 "Articulation": {"type": "array"},
1232 "Slack": {"type": "object"},
1233 "Velocity": {"type": "object"},
1234 "status": {"type": "object"},
1235 "advanced_insights": {"type": "object"},
1236 "usage_hints": {"type": "array"},
1237 },
1238 }));
1239
1240 commands.insert(
1241 "robot-priority".to_string(),
1242 serde_json::json!({
1243 "$schema": "https://json-schema.org/draft/2020-12/schema",
1244 "title": "Robot Priority Output",
1245 "description": "Priority misalignment detection with recommendations",
1246 "type": "object",
1247 "properties": {
1248 "generated_at": schema_prop_dt(),
1249 "data_hash": schema_prop("string"),
1250 "recommendations": {"type": "array"},
1251 "status": {"type": "object"},
1252 "usage_hints": {"type": "array"},
1253 },
1254 }),
1255 );
1256
1257 commands.insert(
1258 "robot-graph".to_string(),
1259 serde_json::json!({
1260 "$schema": "https://json-schema.org/draft/2020-12/schema",
1261 "title": "Robot Graph Output",
1262 "description": "Dependency graph in JSON/DOT/Mermaid format",
1263 "type": "object",
1264 "properties": {
1265 "generated_at": schema_prop_dt(),
1266 "data_hash": schema_prop("string"),
1267 "format": {"type": "string", "enum": ["json", "dot", "mermaid"]},
1268 "nodes": {"type": "array"},
1269 "edges": {"type": "array"},
1270 "stats": {"type": "object"},
1271 },
1272 }),
1273 );
1274
1275 commands.insert(
1276 "robot-diff".to_string(),
1277 serde_json::json!({
1278 "$schema": "https://json-schema.org/draft/2020-12/schema",
1279 "title": "Robot Diff Output",
1280 "description": "Changes since a historical point (commit, branch, date)",
1281 "type": "object",
1282 "properties": {
1283 "generated_at": schema_prop_dt(),
1284 "data_hash": schema_prop("string"),
1285 "since": schema_prop("string"),
1286 "since_commit": schema_prop("string"),
1287 "new": {"type": "array"},
1288 "closed": {"type": "array"},
1289 "modified": {"type": "array"},
1290 "cycles": {"type": "object"},
1291 },
1292 }),
1293 );
1294
1295 commands.insert(
1296 "robot-alerts".to_string(),
1297 serde_json::json!({
1298 "$schema": "https://json-schema.org/draft/2020-12/schema",
1299 "title": "Robot Alerts Output",
1300 "description": "Stale issues, blocking cascades, priority mismatches",
1301 "type": "object",
1302 "properties": {
1303 "generated_at": schema_prop_dt(),
1304 "data_hash": schema_prop("string"),
1305 "alerts": {"type": "array"},
1306 "summary": {"type": "object"},
1307 },
1308 }),
1309 );
1310
1311 commands.insert(
1312 "robot-suggest".to_string(),
1313 serde_json::json!({
1314 "$schema": "https://json-schema.org/draft/2020-12/schema",
1315 "title": "Robot Suggest Output",
1316 "description": "Smart suggestions for duplicates, dependencies, labels, cycle breaks",
1317 "type": "object",
1318 "properties": {
1319 "generated_at": schema_prop_dt(),
1320 "data_hash": schema_prop("string"),
1321 "suggestions": {"type": "array"},
1322 "counts": {"type": "object"},
1323 },
1324 }),
1325 );
1326
1327 commands.insert(
1328 "robot-burndown".to_string(),
1329 serde_json::json!({
1330 "$schema": "https://json-schema.org/draft/2020-12/schema",
1331 "title": "Robot Burndown Output",
1332 "description": "Sprint burndown data with scope changes and at-risk items",
1333 "type": "object",
1334 "properties": {
1335 "generated_at": schema_prop_dt(),
1336 "data_hash": schema_prop("string"),
1337 "output_format": schema_prop("string"),
1338 "version": schema_prop("string"),
1339 "sprint_id": schema_prop("string"),
1340 "burndown": {"type": "array"},
1341 "scope_changes": {"type": "array"},
1342 "at_risk": {"type": "array"},
1343 },
1344 }),
1345 );
1346
1347 commands.insert(
1348 "robot-forecast".to_string(),
1349 serde_json::json!({
1350 "$schema": "https://json-schema.org/draft/2020-12/schema",
1351 "title": "Robot Forecast Output",
1352 "description": "ETA predictions with dependency-aware scheduling",
1353 "type": "object",
1354 "properties": {
1355 "generated_at": schema_prop_dt(),
1356 "data_hash": schema_prop("string"),
1357 "output_format": schema_prop("string"),
1358 "version": schema_prop("string"),
1359 "forecasts": {"type": "array"},
1360 "methodology": {"type": "object"},
1361 },
1362 }),
1363 );
1364
1365 commands.insert(
1366 "robot-history".to_string(),
1367 serde_json::json!({
1368 "$schema": "https://json-schema.org/draft/2020-12/schema",
1369 "title": "Robot History Output",
1370 "description": "Bead-to-commit correlations from git history",
1371 "type": "object",
1372 "properties": {
1373 "generated_at": schema_prop_dt(),
1374 "data_hash": schema_prop("string"),
1375 "beads": {"type": "array"},
1376 "stats": {"type": "object"},
1377 },
1378 }),
1379 );
1380
1381 commands.insert(
1382 "robot-capacity".to_string(),
1383 serde_json::json!({
1384 "$schema": "https://json-schema.org/draft/2020-12/schema",
1385 "title": "Robot Capacity Output",
1386 "description": "Capacity simulation and completion projections",
1387 "type": "object",
1388 "properties": {
1389 "generated_at": schema_prop_dt(),
1390 "data_hash": schema_prop("string"),
1391 "output_format": schema_prop("string"),
1392 "version": schema_prop("string"),
1393 "capacity": {"type": "object"},
1394 "projections": {"type": "array"},
1395 },
1396 }),
1397 );
1398
1399 commands.insert(
1400 "robot-triage-by-track".to_string(),
1401 simple_command_schema(
1402 "Robot Triage By Track Output",
1403 "Triage grouped by parallel execution track",
1404 serde_json::json!({
1405 "generated_at": schema_prop_dt(),
1406 "data_hash": schema_prop("string"),
1407 "triage": {"type": "object"},
1408 "feedback": {"type": "object"},
1409 "usage_hints": {"type": "array"},
1410 }),
1411 ),
1412 );
1413 commands.insert(
1414 "robot-triage-by-label".to_string(),
1415 simple_command_schema(
1416 "Robot Triage By Label Output",
1417 "Triage grouped by label/domain",
1418 serde_json::json!({
1419 "generated_at": schema_prop_dt(),
1420 "data_hash": schema_prop("string"),
1421 "triage": {"type": "object"},
1422 "feedback": {"type": "object"},
1423 "usage_hints": {"type": "array"},
1424 }),
1425 ),
1426 );
1427 commands.insert(
1428 "robot-schema".to_string(),
1429 simple_command_schema(
1430 "Robot Schema Output",
1431 "JSON Schema definitions for robot commands",
1432 serde_json::json!({
1433 "schema_version": schema_prop("string"),
1434 "generated_at": schema_prop_dt(),
1435 "command": schema_prop("string"),
1436 "schema": {"type": "object"},
1437 "envelope": {"type": "object"},
1438 "commands": {"type": "object"},
1439 }),
1440 ),
1441 );
1442 commands.insert(
1443 "robot-docs".to_string(),
1444 simple_command_schema(
1445 "Robot Docs Output",
1446 "Machine-readable documentation for robot command usage",
1447 serde_json::json!({
1448 "generated_at": schema_prop_dt(),
1449 "output_format": schema_prop("string"),
1450 "version": schema_prop("string"),
1451 "topic": schema_prop("string"),
1452 "guide": {"type": "object"},
1453 "commands": {"type": "object"},
1454 "examples": {"type": "array"},
1455 "environment_variables": {"type": "object"},
1456 "exit_codes": {"type": "object"},
1457 "error": schema_prop("string"),
1458 "available_topics": {"type": "array"},
1459 }),
1460 ),
1461 );
1462 commands.insert(
1463 "robot-sprint-list".to_string(),
1464 versioned_simple_command_schema(
1465 "Robot Sprint List Output",
1466 "List of available sprints",
1467 serde_json::json!({
1468 "generated_at": schema_prop_dt(),
1469 "data_hash": schema_prop("string"),
1470 "sprint_count": schema_prop("integer"),
1471 "sprints": {"type": "array"},
1472 }),
1473 ),
1474 );
1475 commands.insert(
1476 "robot-sprint-show".to_string(),
1477 versioned_simple_command_schema(
1478 "Robot Sprint Show Output",
1479 "Single sprint detail payload",
1480 serde_json::json!({
1481 "generated_at": schema_prop_dt(),
1482 "data_hash": schema_prop("string"),
1483 "sprint": {"type": "object"},
1484 }),
1485 ),
1486 );
1487 commands.insert(
1488 "robot-metrics".to_string(),
1489 versioned_simple_command_schema(
1490 "Robot Metrics Output",
1491 "Timing, cache, and memory telemetry",
1492 serde_json::json!({
1493 "generated_at": schema_prop_dt(),
1494 "data_hash": schema_prop("string"),
1495 "timing": {"type": "array"},
1496 "cache": {"type": "array"},
1497 "memory": {"type": "object"},
1498 }),
1499 ),
1500 );
1501 commands.insert(
1502 "robot-label-health".to_string(),
1503 versioned_simple_command_schema(
1504 "Robot Label Health Output",
1505 "Per-label health summary",
1506 serde_json::json!({
1507 "generated_at": schema_prop_dt(),
1508 "data_hash": schema_prop("string"),
1509 "analysis_config": {"type": "object"},
1510 "results": {"type": "object"},
1511 "usage_hints": {"type": "array"},
1512 }),
1513 ),
1514 );
1515 commands.insert(
1516 "robot-label-flow".to_string(),
1517 versioned_simple_command_schema(
1518 "Robot Label Flow Output",
1519 "Cross-label flow summary",
1520 serde_json::json!({
1521 "generated_at": schema_prop_dt(),
1522 "data_hash": schema_prop("string"),
1523 "analysis_config": {"type": "object"},
1524 "flow": {"type": "object"},
1525 "usage_hints": {"type": "array"},
1526 }),
1527 ),
1528 );
1529 commands.insert(
1530 "robot-label-attention".to_string(),
1531 versioned_simple_command_schema(
1532 "Robot Label Attention Output",
1533 "Attention-ranked labels",
1534 serde_json::json!({
1535 "generated_at": schema_prop_dt(),
1536 "data_hash": schema_prop("string"),
1537 "limit": schema_prop("integer"),
1538 "labels": {"type": "array"},
1539 "total_labels": schema_prop("integer"),
1540 "usage_hints": {"type": "array"},
1541 }),
1542 ),
1543 );
1544 commands.insert(
1545 "robot-explain-correlation".to_string(),
1546 simple_command_schema(
1547 "Robot Explain Correlation Output",
1548 "Explanation for a commit-to-bead correlation",
1549 serde_json::json!({
1550 "generated_at": schema_prop_dt(),
1551 "data_hash": schema_prop("string"),
1552 "explanation": {"type": "object"},
1553 }),
1554 ),
1555 );
1556 commands.insert(
1557 "robot-confirm-correlation".to_string(),
1558 simple_command_schema(
1559 "Robot Confirm Correlation Output",
1560 "Confirmation feedback result for a correlation candidate",
1561 serde_json::json!({
1562 "status": schema_prop("string"),
1563 "commit": schema_prop("string"),
1564 "bead": schema_prop("string"),
1565 "by": schema_prop("string"),
1566 "reason": schema_prop("string"),
1567 "orig_conf": schema_prop("number"),
1568 }),
1569 ),
1570 );
1571 commands.insert(
1572 "robot-reject-correlation".to_string(),
1573 simple_command_schema(
1574 "Robot Reject Correlation Output",
1575 "Rejection feedback result for a correlation candidate",
1576 serde_json::json!({
1577 "status": schema_prop("string"),
1578 "commit": schema_prop("string"),
1579 "bead": schema_prop("string"),
1580 "by": schema_prop("string"),
1581 "reason": schema_prop("string"),
1582 "orig_conf": schema_prop("number"),
1583 }),
1584 ),
1585 );
1586 commands.insert(
1587 "robot-correlation-stats".to_string(),
1588 versioned_simple_command_schema(
1589 "Robot Correlation Stats Output",
1590 "Stored correlation feedback statistics",
1591 serde_json::json!({
1592 "generated_at": schema_prop_dt(),
1593 "data_hash": schema_prop("string"),
1594 "total_feedback": schema_prop("integer"),
1595 "confirmed": schema_prop("integer"),
1596 "rejected": schema_prop("integer"),
1597 "ignored": schema_prop("integer"),
1598 "accuracy_rate": schema_prop("number"),
1599 "avg_confirm_conf": schema_prop("number"),
1600 "avg_reject_conf": schema_prop("number"),
1601 }),
1602 ),
1603 );
1604 commands.insert(
1605 "robot-orphans".to_string(),
1606 versioned_simple_command_schema(
1607 "Robot Orphans Output",
1608 "Repository orphan-file report",
1609 serde_json::json!({
1610 "generated_at": schema_prop_dt(),
1611 "data_hash": schema_prop("string"),
1612 "stats": {"type": "object"},
1613 "candidates": {"type": "array"},
1614 }),
1615 ),
1616 );
1617 commands.insert(
1618 "robot-file-beads".to_string(),
1619 versioned_simple_command_schema(
1620 "Robot File Beads Output",
1621 "Beads related to a file path",
1622 serde_json::json!({
1623 "generated_at": schema_prop_dt(),
1624 "data_hash": schema_prop("string"),
1625 "file_path": schema_prop("string"),
1626 "open_beads": {"type": "array"},
1627 "closed_beads": {"type": "array"},
1628 "total_beads": schema_prop("integer"),
1629 }),
1630 ),
1631 );
1632 commands.insert(
1633 "robot-file-hotspots".to_string(),
1634 versioned_simple_command_schema(
1635 "Robot File Hotspots Output",
1636 "Hotspot ranking derived from file history",
1637 serde_json::json!({
1638 "generated_at": schema_prop_dt(),
1639 "data_hash": schema_prop("string"),
1640 "hotspots": {"type": "array"},
1641 "stats": {"type": "object"},
1642 }),
1643 ),
1644 );
1645 commands.insert(
1646 "robot-impact".to_string(),
1647 versioned_simple_command_schema(
1648 "Robot Impact Output",
1649 "Impact analysis for one or more file paths",
1650 serde_json::json!({
1651 "generated_at": schema_prop_dt(),
1652 "data_hash": schema_prop("string"),
1653 "files": {"type": "array"},
1654 "affected_beads": {"type": "array"},
1655 "risk_level": schema_prop("string"),
1656 "risk_score": schema_prop("number"),
1657 "summary": schema_prop("string"),
1658 }),
1659 ),
1660 );
1661 commands.insert(
1662 "robot-file-relations".to_string(),
1663 versioned_simple_command_schema(
1664 "Robot File Relations Output",
1665 "Related files derived from bead/file co-occurrence",
1666 serde_json::json!({
1667 "generated_at": schema_prop_dt(),
1668 "data_hash": schema_prop("string"),
1669 "source_file": schema_prop("string"),
1670 "related_files": {"type": "array"},
1671 "total_commits_for_source": schema_prop("integer"),
1672 }),
1673 ),
1674 );
1675 commands.insert(
1676 "robot-related".to_string(),
1677 versioned_simple_command_schema(
1678 "Robot Related Output",
1679 "Related work recommendations for a bead",
1680 serde_json::json!({
1681 "generated_at": schema_prop_dt(),
1682 "data_hash": schema_prop("string"),
1683 "source_bead": schema_prop("string"),
1684 "related": {"type": "array"},
1685 }),
1686 ),
1687 );
1688 commands.insert(
1689 "robot-blocker-chain".to_string(),
1690 versioned_simple_command_schema(
1691 "Robot Blocker Chain Output",
1692 "Upstream blocker chain for a bead",
1693 serde_json::json!({
1694 "generated_at": schema_prop_dt(),
1695 "data_hash": schema_prop("string"),
1696 "target_id": schema_prop("string"),
1697 "target_title": schema_prop("string"),
1698 "is_blocked": schema_prop("boolean"),
1699 "chain_length": schema_prop("integer"),
1700 "root_blockers": {"type": "array"},
1701 "chain": {"type": "array"},
1702 "has_cycle": schema_prop("boolean"),
1703 "cycle_ids": {"type": "array"},
1704 }),
1705 ),
1706 );
1707 commands.insert(
1708 "robot-impact-network".to_string(),
1709 versioned_simple_command_schema(
1710 "Robot Impact Network Output",
1711 "Impact network around a bead",
1712 serde_json::json!({
1713 "generated_at": schema_prop_dt(),
1714 "data_hash": schema_prop("string"),
1715 "bead_id": schema_prop("string"),
1716 "depth": schema_prop("integer"),
1717 "network": {"type": "object"},
1718 "top_connected": {"type": "array"},
1719 }),
1720 ),
1721 );
1722 commands.insert(
1723 "robot-causality".to_string(),
1724 versioned_simple_command_schema(
1725 "Robot Causality Output",
1726 "Causality chain around a bead",
1727 serde_json::json!({
1728 "generated_at": schema_prop_dt(),
1729 "data_hash": schema_prop("string"),
1730 "chain": {"type": "object"},
1731 "insights": {"type": "object"},
1732 }),
1733 ),
1734 );
1735 commands.insert(
1736 "robot-drift".to_string(),
1737 versioned_simple_command_schema(
1738 "Robot Drift Output",
1739 "Structured baseline drift result",
1740 serde_json::json!({
1741 "generated_at": schema_prop_dt(),
1742 "data_hash": schema_prop("string"),
1743 "has_drift": schema_prop("boolean"),
1744 "exit_code": schema_prop("integer"),
1745 "summary": {"type": "object"},
1746 "alerts": {"type": "array"},
1747 "baseline": {"type": "object"},
1748 }),
1749 ),
1750 );
1751 commands.insert(
1752 "robot-search".to_string(),
1753 versioned_simple_command_schema(
1754 "Robot Search Output",
1755 "Search results over beads",
1756 serde_json::json!({
1757 "generated_at": schema_prop_dt(),
1758 "data_hash": schema_prop("string"),
1759 "query": schema_prop("string"),
1760 "limit": schema_prop("integer"),
1761 "mode": schema_prop("string"),
1762 "preset": schema_prop("string"),
1763 "weights": {"type": "object"},
1764 "results": {"type": "array"},
1765 "usage_hints": {"type": "array"},
1766 }),
1767 ),
1768 );
1769 commands.insert(
1770 "robot-recipes".to_string(),
1771 versioned_simple_command_schema(
1772 "Robot Recipes Output",
1773 "Available named triage recipes",
1774 serde_json::json!({
1775 "generated_at": schema_prop_dt(),
1776 "data_hash": schema_prop("string"),
1777 "recipes": {"type": "array"},
1778 }),
1779 ),
1780 );
1781 commands.insert(
1782 "robot-economics".to_string(),
1783 versioned_simple_command_schema(
1784 "Robot Economics Output",
1785 "Operating-cost projection: burn rate, cost-to-complete, cost-of-delay. Pure arithmetic over analyzer state + opt-in overlay.",
1786 serde_json::json!({
1787 "generated_at": schema_prop_dt(),
1788 "data_hash": schema_prop("string"),
1789 "schema_version": schema_prop("string"),
1790 "overlay_hash": schema_prop("string"),
1791 "inputs": {
1792 "type": "object",
1793 "properties": {
1794 "hourly_rate": schema_prop("number"),
1795 "hours_per_day": schema_prop("number"),
1796 "budget_envelope": schema_prop("number"),
1797 "currency": schema_prop("string"),
1798 "throughput_window_days": schema_prop("integer"),
1799 "project_age_days": schema_prop("integer"),
1800 "estimate_coverage_pct": schema_prop("number"),
1801 "open_issues": schema_prop("integer"),
1802 "closed_in_window": schema_prop("integer"),
1803 },
1804 "required": [
1805 "hourly_rate", "hours_per_day", "throughput_window_days",
1806 "project_age_days", "estimate_coverage_pct",
1807 "open_issues", "closed_in_window",
1808 ],
1809 },
1810 "projections": {
1811 "type": "object",
1812 "properties": {
1813 "burn_rate_per_day": schema_prop("number"),
1814 "throughput_issues_per_day": schema_prop("number"),
1815 "cost_to_complete": schema_prop("number"),
1816 "budget_utilization_pct": schema_prop("number"),
1817 "cost_of_delay": {
1818 "type": "array",
1819 "items": {
1820 "type": "object",
1821 "properties": {
1822 "id": schema_prop("string"),
1823 "title": schema_prop("string"),
1824 "dependents_count": schema_prop("integer"),
1825 "rate_per_day": schema_prop("number"),
1826 },
1827 "required": ["id", "dependents_count", "rate_per_day"],
1828 },
1829 },
1830 },
1831 "required": [
1832 "burn_rate_per_day", "throughput_issues_per_day", "cost_of_delay",
1833 ],
1834 },
1835 "guards": {
1836 "type": "object",
1837 "properties": {
1838 "estimate_coverage_below_threshold": schema_prop("boolean"),
1839 "project_too_young_for_throughput": schema_prop("boolean"),
1840 "zero_throughput": schema_prop("boolean"),
1841 "no_budget_envelope": schema_prop("boolean"),
1842 },
1843 "required": [
1844 "estimate_coverage_below_threshold",
1845 "project_too_young_for_throughput",
1846 "zero_throughput",
1847 "no_budget_envelope",
1848 ],
1849 },
1850 }),
1851 ),
1852 );
1853 commands.insert(
1854 "robot-delivery".to_string(),
1855 versioned_simple_command_schema(
1856 "Robot Delivery Output",
1857 "Delivery posture: Reinertsen flow_distribution, urgency_profile, milestone_pressure. Classification only.",
1858 serde_json::json!({
1859 "generated_at": schema_prop_dt(),
1860 "data_hash": schema_prop("string"),
1861 "schema_version": schema_prop("string"),
1862 "open_issues": schema_prop("integer"),
1863 "window_days": schema_prop("integer"),
1864 "flow_distribution": {
1865 "type": "array",
1866 "items": {
1867 "type": "object",
1868 "properties": {
1869 "category": {
1870 "type": "string",
1871 "enum": ["risk", "debt", "defects", "features"],
1872 },
1873 "count": schema_prop("integer"),
1874 "pct": schema_prop("number"),
1875 },
1876 "required": ["category", "count", "pct"],
1877 },
1878 },
1879 "urgency_profile": {
1880 "type": "array",
1881 "items": {
1882 "type": "object",
1883 "properties": {
1884 "category": {
1885 "type": "string",
1886 "enum": ["expedite", "fixed_date", "intangible", "standard"],
1887 },
1888 "count": schema_prop("integer"),
1889 "pct": schema_prop("number"),
1890 },
1891 "required": ["category", "count", "pct"],
1892 },
1893 },
1894 "milestone_pressure": {
1895 "type": "array",
1896 "items": {
1897 "type": "object",
1898 "properties": {
1899 "id": schema_prop("string"),
1900 "title": schema_prop("string"),
1901 "due_date": schema_prop_dt(),
1902 "days_until_due": schema_prop("integer"),
1903 "is_overdue": schema_prop("boolean"),
1904 "is_blocked": schema_prop("boolean"),
1905 },
1906 "required": ["id", "due_date", "days_until_due", "is_overdue", "is_blocked"],
1907 },
1908 },
1909 }),
1910 ),
1911 );
1912
1913 RobotSchemas {
1914 schema_version: env!("CARGO_PKG_VERSION").to_string(),
1915 generated_at: now,
1916 envelope,
1917 commands,
1918 }
1919}
1920
1921#[must_use]
1926pub fn estimate_tokens(s: &str) -> usize {
1927 let trimmed = s.trim();
1928 if trimmed.is_empty() {
1929 return 0;
1930 }
1931 trimmed.len().div_ceil(4)
1932}
1933
1934pub fn print_format_stats(json_output: &str, toon_output: Option<&str>) {
1935 let json_tokens = estimate_tokens(json_output);
1936 if let Some(toon) = toon_output {
1937 let toon_tokens = estimate_tokens(toon);
1938 let savings = if json_tokens > 0 && toon_tokens <= json_tokens {
1939 ((json_tokens - toon_tokens) * 100) / json_tokens
1940 } else {
1941 0
1942 };
1943 eprintln!("[stats] JSON~{json_tokens} tok, TOON~{toon_tokens} tok ({savings}% savings)");
1944 } else {
1945 eprintln!("Format stats:");
1946 eprintln!(
1947 " JSON: ~{json_tokens} tokens ({} bytes)",
1948 json_output.len()
1949 );
1950 }
1951}
1952
1953#[cfg(test)]
1954mod tests {
1955 use super::*;
1956 use serde_json::json;
1957
1958 #[test]
1961 fn robot_docs_guide_has_required_fields() {
1962 let docs = generate_robot_docs("guide");
1963 assert!(docs["generated_at"].is_string());
1964 assert_eq!(docs["output_format"], "json");
1965 assert_eq!(docs["topic"], "guide");
1966 assert!(docs["guide"]["description"].is_string());
1967 assert!(docs["guide"]["quickstart"].is_array());
1968 assert!(docs["guide"]["data_source"].is_string());
1969 assert!(docs["guide"]["output_modes"].is_object());
1970 }
1971
1972 #[test]
1973 fn robot_docs_commands_lists_all_robot_commands() {
1974 let docs = generate_robot_docs("commands");
1975 let commands = docs["commands"].as_object().unwrap();
1976 let expected = implemented_robot_command_names();
1977 assert_eq!(
1978 commands.len(),
1979 expected.len(),
1980 "expected {} commands, got {}",
1981 expected.len(),
1982 commands.len()
1983 );
1984 for cmd in expected {
1985 assert!(commands.contains_key(*cmd), "missing docs entry for {cmd}");
1986 }
1987 }
1988
1989 #[test]
1990 fn robot_docs_examples_is_array() {
1991 let docs = generate_robot_docs("examples");
1992 assert!(docs["examples"].is_array());
1993 let examples = docs["examples"].as_array().unwrap();
1994 assert!(!examples.is_empty());
1995 assert!(examples[0]["description"].is_string());
1996 assert!(examples[0]["command"].is_string());
1997 }
1998
1999 #[test]
2000 fn robot_docs_env_vars_present() {
2001 let docs = generate_robot_docs("env");
2002 let env = docs["environment_variables"].as_object().unwrap();
2003 assert!(env.contains_key("BV_OUTPUT_FORMAT"));
2004 assert!(env.contains_key("TOON_STATS"));
2005 }
2006
2007 #[test]
2008 fn robot_docs_exit_codes_present() {
2009 let docs = generate_robot_docs("exit-codes");
2010 let codes = docs["exit_codes"].as_object().unwrap();
2011 assert!(codes.contains_key("0"));
2012 assert!(codes.contains_key("1"));
2013 assert!(codes.contains_key("2"));
2014 }
2015
2016 #[test]
2017 fn robot_docs_all_includes_every_section() {
2018 let docs = generate_robot_docs("all");
2019 assert!(docs["guide"].is_object());
2020 assert!(docs["commands"].is_object());
2021 assert!(docs["examples"].is_array());
2022 assert!(docs["environment_variables"].is_object());
2023 assert!(docs["exit_codes"].is_object());
2024 }
2025
2026 #[test]
2027 fn robot_docs_invalid_topic_returns_error() {
2028 let docs = generate_robot_docs("nonsense");
2029 assert!(docs["error"].is_string());
2030 assert!(docs["available_topics"].is_array());
2031 let topics = docs["available_topics"].as_array().unwrap();
2032 assert!(topics.contains(&serde_json::json!("all")));
2033 }
2034
2035 #[test]
2036 fn robot_docs_version_matches_cargo() {
2037 let docs = generate_robot_docs("guide");
2038 assert_eq!(docs["version"], env!("CARGO_PKG_VERSION"));
2039 }
2040
2041 #[test]
2042 fn robot_docs_command_entries_match_current_flattened_payloads() {
2043 let docs = generate_robot_docs("commands");
2044 let commands = docs["commands"].as_object().unwrap();
2045
2046 assert_eq!(
2047 commands["robot-correlation-stats"]["key_fields"],
2048 json!(["total_feedback", "confirmed", "rejected", "accuracy_rate"])
2049 );
2050 assert_eq!(
2051 commands["robot-orphans"]["key_fields"],
2052 json!(["stats", "candidates"])
2053 );
2054 assert_eq!(
2055 commands["robot-search"]["key_fields"],
2056 json!(["query", "limit", "mode", "results"])
2057 );
2058 assert_eq!(
2059 commands["robot-label-health"]["key_fields"],
2060 json!(["analysis_config", "results"])
2061 );
2062 assert_eq!(
2063 commands["robot-label-flow"]["key_fields"],
2064 json!(["analysis_config", "flow"])
2065 );
2066 assert_eq!(
2067 commands["robot-label-attention"]["key_fields"],
2068 json!(["limit", "labels", "total_labels"])
2069 );
2070 assert_eq!(
2071 commands["robot-overview"]["key_fields"],
2072 json!([
2073 "summary",
2074 "top_pick",
2075 "top_blocker",
2076 "top_labels",
2077 "fronts",
2078 "commands"
2079 ])
2080 );
2081 }
2082
2083 #[test]
2086 fn robot_schema_has_required_top_level_fields() {
2087 let schemas = generate_robot_schemas();
2088 assert_eq!(schemas.schema_version, env!("CARGO_PKG_VERSION"));
2089 assert!(!schemas.generated_at.is_empty());
2090 assert!(schemas.envelope.is_object());
2091 assert!(!schemas.commands.is_empty());
2092 }
2093
2094 #[test]
2095 fn robot_schema_envelope_has_core_properties() {
2096 let schemas = generate_robot_schemas();
2097 let props = schemas.envelope["properties"].as_object().unwrap();
2098 assert!(props.contains_key("generated_at"));
2099 assert!(props.contains_key("data_hash"));
2100 assert!(props.contains_key("output_format"));
2101 assert!(props.contains_key("version"));
2102 }
2103
2104 #[test]
2105 fn robot_schema_covers_all_implemented_commands() {
2106 let schemas = generate_robot_schemas();
2107 for cmd in implemented_robot_command_names() {
2108 assert!(
2109 schemas.commands.contains_key(*cmd),
2110 "missing schema for {cmd}"
2111 );
2112 }
2113 }
2114
2115 #[test]
2116 fn robot_docs_and_schema_command_sets_match() {
2117 let docs = generate_robot_docs("commands");
2118 let docs_commands = docs["commands"].as_object().unwrap();
2119 let schemas = generate_robot_schemas();
2120
2121 for cmd in implemented_robot_command_names() {
2122 assert!(docs_commands.contains_key(*cmd), "docs missing {cmd}");
2123 assert!(schemas.commands.contains_key(*cmd), "schema missing {cmd}");
2124 }
2125 }
2126
2127 #[test]
2128 fn robot_schema_triage_has_defs() {
2129 let schemas = generate_robot_schemas();
2130 let triage = &schemas.commands["robot-triage"];
2131 assert!(triage["$defs"].is_object());
2132 assert!(triage["$defs"]["recommendation"].is_object());
2133 }
2134
2135 #[test]
2136 fn robot_schema_each_command_has_type_object() {
2137 let schemas = generate_robot_schemas();
2138 for (name, schema) in &schemas.commands {
2139 assert_eq!(
2140 schema["type"], "object",
2141 "schema for {name} should be type: object"
2142 );
2143 }
2144 }
2145
2146 #[test]
2147 fn robot_schema_overview_has_summary_and_commands() {
2148 let schemas = generate_robot_schemas();
2149 let overview = &schemas.commands["robot-overview"];
2150 assert!(overview["properties"]["summary"].is_object());
2151 assert!(overview["properties"]["commands"].is_object());
2152 }
2153
2154 #[test]
2157 fn estimate_tokens_empty_is_zero() {
2158 assert_eq!(estimate_tokens(""), 0);
2159 assert_eq!(estimate_tokens(" "), 0);
2160 }
2161
2162 #[test]
2163 fn estimate_tokens_short_string() {
2164 assert_eq!(estimate_tokens("abcd"), 1);
2165 assert_eq!(estimate_tokens("abcde"), 2);
2166 }
2167
2168 #[test]
2169 fn estimate_tokens_matches_go_heuristic() {
2170 let s = "hello world test string";
2172 let expected = s.len().div_ceil(4);
2173 assert_eq!(estimate_tokens(s), expected);
2174 }
2175
2176 #[test]
2177 fn render_payload_sets_output_format_for_json() {
2178 let payload = json!({
2179 "generated_at": "2026-03-07T00:00:00Z",
2180 "data_hash": "abc123",
2181 "output_format": "json",
2182 "version": format!("v{}", env!("CARGO_PKG_VERSION"))
2183 });
2184
2185 let rendered = render_payload(OutputFormat::Json, &payload).expect("rendered payload");
2186 let json: Value = serde_json::from_str(&rendered.output).expect("json output");
2187 assert_eq!(json["output_format"].as_str(), Some("json"));
2188 assert!(rendered.toon_for_stats.is_none());
2189 }
2190
2191 #[test]
2192 fn print_format_stats_supports_toon_comparison() {
2193 print_format_stats("{\"id\":\"A\"}", Some("id: A\n"));
2194 }
2195
2196 #[test]
2197 fn parse_toon_key_folding_mode_supports_safe_and_off() {
2198 assert_eq!(
2199 parse_toon_key_folding_mode("safe"),
2200 Some(KeyFoldingMode::Safe)
2201 );
2202 assert_eq!(parse_toon_key_folding_mode("off"), None);
2203 }
2204
2205 #[test]
2208 fn compute_data_hash_is_deterministic() {
2209 let issues = vec![
2210 Issue {
2211 id: "A".to_string(),
2212 status: "open".to_string(),
2213 priority: 1,
2214 ..Default::default()
2215 },
2216 Issue {
2217 id: "B".to_string(),
2218 status: "closed".to_string(),
2219 priority: 2,
2220 ..Default::default()
2221 },
2222 ];
2223 let h1 = compute_data_hash(&issues);
2224 let h2 = compute_data_hash(&issues);
2225 assert_eq!(h1, h2);
2226 assert_eq!(h1.len(), 16, "hash should be 16 hex chars");
2227 }
2228
2229 #[test]
2230 fn compute_data_hash_is_order_independent() {
2231 let issues_ab = vec![
2232 Issue {
2233 id: "A".to_string(),
2234 status: "open".to_string(),
2235 ..Default::default()
2236 },
2237 Issue {
2238 id: "B".to_string(),
2239 status: "closed".to_string(),
2240 ..Default::default()
2241 },
2242 ];
2243 let issues_ba = vec![issues_ab[1].clone(), issues_ab[0].clone()];
2244 assert_eq!(
2245 compute_data_hash(&issues_ab),
2246 compute_data_hash(&issues_ba),
2247 "hash should be independent of issue order"
2248 );
2249 }
2250
2251 #[test]
2252 fn compute_data_hash_empty_issues() {
2253 let h = compute_data_hash(&[]);
2254 assert_eq!(h.len(), 16);
2255 }
2256
2257 #[test]
2258 fn compute_data_hash_changes_with_data() {
2259 let v1 = vec![Issue {
2260 id: "A".to_string(),
2261 status: "open".to_string(),
2262 ..Default::default()
2263 }];
2264 let v2 = vec![Issue {
2265 id: "A".to_string(),
2266 status: "closed".to_string(),
2267 ..Default::default()
2268 }];
2269 assert_ne!(
2270 compute_data_hash(&v1),
2271 compute_data_hash(&v2),
2272 "different status should produce different hash"
2273 );
2274 }
2275
2276 #[test]
2279 fn envelope_produces_valid_fields() {
2280 let issues = vec![Issue {
2281 id: "X".to_string(),
2282 status: "open".to_string(),
2283 ..Default::default()
2284 }];
2285 let env = envelope(&issues);
2286 assert!(!env.generated_at.is_empty());
2287 assert_eq!(env.data_hash.len(), 16);
2288 assert!(env.generated_at.contains('T'));
2290 assert_eq!(env.output_format, "json");
2291 assert!(env.version.starts_with('v'));
2292 }
2293
2294 #[test]
2295 fn envelope_empty_has_empty_hash() {
2296 let env = envelope_empty();
2297 assert!(!env.generated_at.is_empty());
2298 assert!(env.data_hash.is_empty());
2299 assert_eq!(env.output_format, "json");
2300 assert!(env.version.starts_with('v'));
2301 }
2302
2303 #[test]
2306 fn default_field_descriptions_has_core_fields() {
2307 let desc = default_field_descriptions();
2308 assert!(desc.contains_key("score"));
2309 assert!(desc.contains_key("confidence"));
2310 assert!(desc.contains_key("unblocks"));
2311 for (key, value) in &desc {
2313 assert!(
2314 !value.is_empty(),
2315 "description for {key} should not be empty"
2316 );
2317 }
2318 }
2319
2320 #[test]
2323 fn encode_toon_produces_output() {
2324 let value = json!({
2325 "id": "A",
2326 "score": 0.5
2327 });
2328 let result = encode_toon(&value);
2329 assert!(!result.is_empty());
2330 assert!(result.ends_with('\n'));
2331 assert!(
2332 result.contains("id:") || result.contains("id: A"),
2333 "TOON output should use key: value format"
2334 );
2335 }
2336
2337 #[test]
2338 fn resolve_toon_encode_options_parses_env() {
2339 let opts = resolve_toon_encode_options();
2341 let _ = opts.key_folding;
2345 let _ = opts.indent;
2346 }
2347
2348 #[test]
2349 fn set_top_level_output_format_patches_toon() {
2350 let mut value = json!({
2351 "output_format": "json",
2352 "data_hash": "abc"
2353 });
2354 set_top_level_output_format(&mut value, OutputFormat::Toon);
2355 assert_eq!(value["output_format"], "toon");
2356 }
2357
2358 #[test]
2359 fn set_top_level_output_format_skips_when_absent() {
2360 let mut value = json!({"data_hash": "abc"});
2361 set_top_level_output_format(&mut value, OutputFormat::Toon);
2362 assert!(value.get("output_format").is_none());
2363 }
2364
2365 #[test]
2366 fn render_payload_toon_sets_output_format_field() {
2367 let payload = json!({
2368 "output_format": "json",
2369 "data_hash": "abc123"
2370 });
2371 let rendered = render_payload(OutputFormat::Toon, &payload).expect("rendered");
2372 let stats_json: Value =
2373 serde_json::from_str(&rendered.json_for_stats).expect("parse stats json");
2374 assert_eq!(stats_json["output_format"], "toon");
2375 assert!(rendered.toon_for_stats.is_some());
2376 }
2377
2378 #[test]
2379 fn render_payload_toon_output_is_not_json() {
2380 let payload = json!({
2381 "output_format": "toon",
2382 "data_hash": "abc123"
2383 });
2384 let rendered = render_payload(OutputFormat::Toon, &payload).expect("rendered");
2385 assert!(rendered.toon_for_stats.is_some());
2386 assert!(!rendered.output.trim_start().starts_with('{'));
2387 }
2388
2389 #[test]
2390 fn print_format_stats_json_only_no_toon() {
2391 print_format_stats(r#"{"id":"A","count":10}"#, None);
2392 }
2393
2394 #[test]
2395 fn print_format_stats_with_savings() {
2396 let json = r#"{"status":"open","priority":1,"labels":["backend","api"]}"#;
2397 let toon = "status: open\npriority: 1\nlabels: backend,api\n";
2398 print_format_stats(json, Some(toon));
2399 }
2400
2401 #[test]
2402 fn encode_json_respects_pretty_flag() {
2403 let value = json!({"a": 1, "b": 2});
2404 let compact = encode_json(&value).expect("compact json");
2405 assert!(
2406 !compact.contains('\n'),
2407 "compact JSON should be single line"
2408 );
2409 }
2410}