Skip to main content

bvr/
robot.rs

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/// Create an envelope without issues (for commands that don't load issues).
33#[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
103// ---------------------------------------------------------------------------
104// TOON encoding (direct in-process library integration)
105// ---------------------------------------------------------------------------
106
107/// Options for TOON encoding, resolved from environment variables.
108struct 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    // Normalize: trim trailing whitespace, add single newline
224    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// ---------------------------------------------------------------------------
251// --robot-docs
252// ---------------------------------------------------------------------------
253
254#[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// ---------------------------------------------------------------------------
923// --robot-schema
924// ---------------------------------------------------------------------------
925
926#[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// ---------------------------------------------------------------------------
1922// --stats (format token estimation)
1923// ---------------------------------------------------------------------------
1924
1925#[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    // --robot-docs tests
1959
1960    #[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    // --robot-schema tests
2084
2085    #[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    // estimate_tokens tests
2155
2156    #[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        // Go: (len(trimmed) + 3) / 4
2171        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    // -- compute_data_hash tests --
2206
2207    #[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    // -- envelope tests --
2277
2278    #[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        // generated_at should be a parseable RFC3339 timestamp
2289        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    // -- default_field_descriptions tests --
2304
2305    #[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        // All values should be non-empty
2312        for (key, value) in &desc {
2313            assert!(
2314                !value.is_empty(),
2315                "description for {key} should not be empty"
2316            );
2317        }
2318    }
2319
2320    // -- TOON encoding tests --
2321
2322    #[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        // Test with default env (no TOON_* vars set in test)
2340        let opts = resolve_toon_encode_options();
2341        // key_folding should be None unless env is set to non-"off" value
2342        // indent should be None unless env is set
2343        // Just verify it doesn't panic
2344        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}