1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
7pub struct CompactHelpEntryPointV1 {
8 pub built_in_routes: Vec<String>,
10 pub mounted_app_routes: Vec<String>,
12 pub plugin_routes: Vec<String>,
14 pub next_commands: Vec<String>,
16}
17
18pub fn build_compact_operator_help_entrypoint(
20 built_in_routes: &[&str],
21 mounted_app_routes: &[&str],
22 plugin_routes: &[&str],
23 next_commands: &[&str],
24) -> Result<CompactHelpEntryPointV1, String> {
25 let normalized_built_ins = unique_sorted_non_empty(built_in_routes)?;
26 for required in ["dag", "config", "doctor", "plugins"] {
27 if !normalized_built_ins.iter().any(|entry| entry == required) {
28 return Err(format!("built_in_routes missing required root command `{required}`"));
29 }
30 }
31 Ok(CompactHelpEntryPointV1 {
32 built_in_routes: normalized_built_ins,
33 mounted_app_routes: unique_sorted_non_empty(mounted_app_routes)?,
34 plugin_routes: unique_sorted_non_empty(plugin_routes)?,
35 next_commands: unique_sorted_non_empty(next_commands)?,
36 })
37}
38
39fn unique_sorted_non_empty(values: &[&str]) -> Result<Vec<String>, String> {
40 let mut normalized: Vec<String> = values
41 .iter()
42 .map(|value| value.trim())
43 .filter(|value| !value.is_empty())
44 .map(ToString::to_string)
45 .collect();
46 normalized.sort();
47 normalized.dedup();
48 if normalized.is_empty() {
49 return Err("route/help collections cannot be empty".to_string());
50 }
51 Ok(normalized)
52}
53
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
56pub struct ScriptStableCommandEnvelopeV1 {
57 pub schema_version: String,
59 pub command: String,
61 pub ok: bool,
63 pub code: String,
65 pub data: Value,
67 pub warnings: Vec<String>,
69 pub errors: Vec<String>,
71}
72
73pub fn build_script_stable_command_envelope(
75 schema_version: &str,
76 command: &str,
77 ok: bool,
78 code: &str,
79 data: Value,
80 warnings: Vec<String>,
81 errors: Vec<String>,
82) -> Result<ScriptStableCommandEnvelopeV1, String> {
83 if schema_version.trim().is_empty() {
84 return Err("schema_version cannot be empty".to_string());
85 }
86 if command.trim().is_empty() {
87 return Err("command cannot be empty".to_string());
88 }
89 if code.trim().is_empty() {
90 return Err("code cannot be empty".to_string());
91 }
92 if ok && !errors.is_empty() {
93 return Err("success envelope cannot contain errors".to_string());
94 }
95 if !ok && errors.is_empty() {
96 return Err("failure envelope must contain at least one error".to_string());
97 }
98 Ok(ScriptStableCommandEnvelopeV1 {
99 schema_version: schema_version.to_string(),
100 command: command.to_string(),
101 ok,
102 code: code.to_string(),
103 data,
104 warnings,
105 errors,
106 })
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
111#[serde(rename_all = "snake_case")]
112pub enum ActionableFailureClassV1 {
113 Parse,
114 Config,
115 Plugin,
116 Dag,
117 Io,
118 Runtime,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
123pub struct ActionableErrorEnvelopeV1 {
124 pub failure_class: ActionableFailureClassV1,
126 pub code: String,
128 pub message: String,
130 pub remediation: String,
132 pub evidence_pointer: Option<String>,
134}
135
136pub fn build_actionable_error_envelope(
138 failure_class: ActionableFailureClassV1,
139 code: &str,
140 message: &str,
141 remediation: &str,
142 evidence_pointer: Option<&str>,
143) -> Result<ActionableErrorEnvelopeV1, String> {
144 if code.trim().is_empty() {
145 return Err("code cannot be empty".to_string());
146 }
147 if message.trim().is_empty() {
148 return Err("message cannot be empty".to_string());
149 }
150 if remediation.trim().is_empty() {
151 return Err("remediation cannot be empty".to_string());
152 }
153 if evidence_pointer.is_some_and(|value| value.trim().is_empty()) {
154 return Err("evidence_pointer cannot be blank when present".to_string());
155 }
156 Ok(ActionableErrorEnvelopeV1 {
157 failure_class,
158 code: code.to_string(),
159 message: message.to_string(),
160 remediation: remediation.to_string(),
161 evidence_pointer: evidence_pointer.map(ToString::to_string),
162 })
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
167pub struct CommandExplainV1 {
168 pub command: String,
170 pub route_target: String,
172 pub handler_source: String,
174 pub output_schema: String,
176 pub side_effect_class: String,
178 pub required_config_keys: Vec<String>,
180}
181
182pub fn build_command_explain_record(
184 command: &str,
185 route_target: &str,
186 handler_source: &str,
187 output_schema: &str,
188 side_effect_class: &str,
189 required_config_keys: &[&str],
190) -> Result<CommandExplainV1, String> {
191 for (field, value) in [
192 ("command", command),
193 ("route_target", route_target),
194 ("handler_source", handler_source),
195 ("output_schema", output_schema),
196 ("side_effect_class", side_effect_class),
197 ] {
198 if value.trim().is_empty() {
199 return Err(format!("{field} cannot be empty"));
200 }
201 }
202 let mut required: Vec<String> = required_config_keys
203 .iter()
204 .map(|value| value.trim())
205 .filter(|value| !value.is_empty())
206 .map(ToString::to_string)
207 .collect();
208 required.sort();
209 required.dedup();
210 Ok(CommandExplainV1 {
211 command: command.to_string(),
212 route_target: route_target.to_string(),
213 handler_source: handler_source.to_string(),
214 output_schema: output_schema.to_string(),
215 side_effect_class: side_effect_class.to_string(),
216 required_config_keys: required,
217 })
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
222pub struct OutputModeParityEntryV1 {
223 pub command: String,
225 pub supported_modes: Vec<String>,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
231pub struct OutputModeParityReportV1 {
232 pub entries: Vec<OutputModeParityEntryV1>,
234 pub missing_mode_commands: Vec<String>,
236 pub parity_complete: bool,
238}
239
240pub fn evaluate_output_mode_parity(
242 entries: Vec<OutputModeParityEntryV1>,
243) -> OutputModeParityReportV1 {
244 let required_modes = ["human", "json", "jsonl", "artifact-output"];
245 let mut ordered_entries = entries;
246 ordered_entries.sort_by(|left, right| left.command.cmp(&right.command));
247 let mut missing = Vec::new();
248 for entry in &ordered_entries {
249 let mut modes = entry.supported_modes.clone();
250 modes.sort();
251 modes.dedup();
252 let has_all_required =
253 required_modes.iter().all(|mode| modes.iter().any(|value| value == mode));
254 if !has_all_required {
255 missing.push(entry.command.clone());
256 }
257 }
258 OutputModeParityReportV1 {
259 entries: ordered_entries,
260 missing_mode_commands: missing.clone(),
261 parity_complete: missing.is_empty(),
262 }
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
267pub struct InstallDiagnosticComponentV1 {
268 pub component: String,
270 pub healthy: bool,
272 pub detail: String,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
278pub struct InstallDiagnosisBundleV1 {
279 pub components: Vec<InstallDiagnosticComponentV1>,
281 pub failing_components: Vec<String>,
283 pub healthy_install: bool,
285}
286
287pub fn build_install_diagnosis_bundle(
289 components: Vec<InstallDiagnosticComponentV1>,
290) -> Result<InstallDiagnosisBundleV1, String> {
291 if components.is_empty() {
292 return Err("components cannot be empty".to_string());
293 }
294 let mut ordered_components = components;
295 ordered_components.sort_by(|left, right| left.component.cmp(&right.component));
296 let failing_components: Vec<String> = ordered_components
297 .iter()
298 .filter(|entry| !entry.healthy)
299 .map(|entry| entry.component.clone())
300 .collect();
301 Ok(InstallDiagnosisBundleV1 {
302 components: ordered_components,
303 failing_components: failing_components.clone(),
304 healthy_install: failing_components.is_empty(),
305 })
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
310pub struct CompletionRouteEntryV1 {
311 pub command: String,
313 pub hidden: bool,
315 pub deprecated: bool,
317 pub stale: bool,
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
323pub struct CompletionSnapshotV1 {
324 pub shell: String,
326 pub commands: Vec<String>,
328}
329
330pub fn build_completion_snapshot_from_registry(
332 shell: &str,
333 entries: Vec<CompletionRouteEntryV1>,
334 include_deprecated: bool,
335) -> Result<CompletionSnapshotV1, String> {
336 if shell.trim().is_empty() {
337 return Err("shell cannot be empty".to_string());
338 }
339 let mut commands: Vec<String> = entries
340 .into_iter()
341 .filter(|entry| !entry.hidden)
342 .filter(|entry| !entry.stale)
343 .filter(|entry| include_deprecated || !entry.deprecated)
344 .map(|entry| entry.command)
345 .collect();
346 commands.sort();
347 commands.dedup();
348 Ok(CompletionSnapshotV1 { shell: shell.to_string(), commands })
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
353#[serde(rename_all = "kebab-case")]
354pub enum CommandSideEffectClassV1 {
355 ReadOnly,
356 WritesConfig,
357 WritesRun,
358 ExecutesAdapter,
359 Destructive,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
364pub struct CommandSideEffectPreviewV1 {
365 pub command: String,
367 pub side_effect_class: CommandSideEffectClassV1,
369 pub requires_confirmation: bool,
371}
372
373pub fn classify_command_side_effect(command: &str) -> Result<CommandSideEffectPreviewV1, String> {
375 if command.trim().is_empty() {
376 return Err("command cannot be empty".to_string());
377 }
378 let normalized = command.trim().to_ascii_lowercase();
379 let class = if normalized.contains("wipe") || normalized.contains("delete") {
380 CommandSideEffectClassV1::Destructive
381 } else if normalized.contains("plugins install")
382 || normalized.contains("plugins uninstall")
383 || normalized.contains("dag run")
384 {
385 CommandSideEffectClassV1::WritesRun
386 } else if normalized.contains("config set") || normalized.contains("config unset") {
387 CommandSideEffectClassV1::WritesConfig
388 } else if normalized.contains("adapter") || normalized.contains("exec") {
389 CommandSideEffectClassV1::ExecutesAdapter
390 } else {
391 CommandSideEffectClassV1::ReadOnly
392 };
393 let requires_confirmation = matches!(
394 class,
395 CommandSideEffectClassV1::WritesRun
396 | CommandSideEffectClassV1::ExecutesAdapter
397 | CommandSideEffectClassV1::Destructive
398 );
399 Ok(CommandSideEffectPreviewV1 {
400 command: command.to_string(),
401 side_effect_class: class,
402 requires_confirmation,
403 })
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
408pub struct PythonBridgeParityEntryV1 {
409 pub command: String,
411 pub rust_machine_output: String,
413 pub python_machine_output: String,
415}
416
417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
419pub struct PythonBridgeParityReportV1 {
420 pub entries: Vec<PythonBridgeParityEntryV1>,
422 pub mismatched_commands: Vec<String>,
424 pub parity_exact: bool,
426}
427
428pub fn build_python_bridge_command_parity_report(
430 entries: Vec<PythonBridgeParityEntryV1>,
431) -> PythonBridgeParityReportV1 {
432 let mut ordered_entries = entries;
433 ordered_entries.sort_by(|left, right| left.command.cmp(&right.command));
434 let mismatched_commands: Vec<String> = ordered_entries
435 .iter()
436 .filter(|entry| entry.rust_machine_output != entry.python_machine_output)
437 .map(|entry| entry.command.clone())
438 .collect();
439 PythonBridgeParityReportV1 {
440 entries: ordered_entries,
441 mismatched_commands: mismatched_commands.clone(),
442 parity_exact: mismatched_commands.is_empty(),
443 }
444}
445
446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
448pub struct OfficialAppRouteDescriptorV1 {
449 pub namespace: String,
451 pub descriptor_id: String,
453 pub priority: i32,
455}
456
457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
459pub struct OfficialAppDiscoveryReportV1 {
460 pub winners: Vec<OfficialAppRouteDescriptorV1>,
462 pub refused_plugin_shadows: Vec<String>,
464 pub refused_path_shims: Vec<String>,
466}
467
468pub fn build_official_app_discovery_report(
470 descriptors: Vec<OfficialAppRouteDescriptorV1>,
471 plugin_shadow_attempts: Vec<String>,
472 path_shim_attempts: Vec<String>,
473) -> OfficialAppDiscoveryReportV1 {
474 use std::collections::BTreeMap;
475
476 let mut winners_by_namespace: BTreeMap<String, OfficialAppRouteDescriptorV1> = BTreeMap::new();
477 for descriptor in descriptors {
478 match winners_by_namespace.get(&descriptor.namespace) {
479 Some(existing) if existing.priority >= descriptor.priority => {}
480 _ => {
481 winners_by_namespace.insert(descriptor.namespace.clone(), descriptor);
482 }
483 }
484 }
485 OfficialAppDiscoveryReportV1 {
486 winners: winners_by_namespace.into_values().collect(),
487 refused_plugin_shadows: plugin_shadow_attempts,
488 refused_path_shims: path_shim_attempts,
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use serde_json::json;
495
496 use super::{
497 build_actionable_error_envelope, build_command_explain_record,
498 build_compact_operator_help_entrypoint, build_completion_snapshot_from_registry,
499 build_install_diagnosis_bundle, build_official_app_discovery_report,
500 build_python_bridge_command_parity_report, build_script_stable_command_envelope,
501 classify_command_side_effect, evaluate_output_mode_parity, ActionableFailureClassV1,
502 CompletionRouteEntryV1, InstallDiagnosticComponentV1, OfficialAppRouteDescriptorV1,
503 OutputModeParityEntryV1, PythonBridgeParityEntryV1,
504 };
505
506 #[test]
507 fn compact_help_entrypoint_requires_core_operator_routes() {
508 let report = build_compact_operator_help_entrypoint(
509 &["doctor", "plugins", "dag", "config"],
510 &["atlas", "genomics"],
511 &["community-tools"],
512 &["bijux dag --help", "bijux doctor"],
513 )
514 .expect("compact help should build");
515 assert_eq!(report.built_in_routes, vec!["config", "dag", "doctor", "plugins"]);
516 assert_eq!(report.mounted_app_routes, vec!["atlas", "genomics"]);
517 }
518
519 #[test]
520 fn script_stable_command_envelope_uses_required_machine_fields() {
521 let envelope = build_script_stable_command_envelope(
522 "command-envelope-v1",
523 "bijux dag plan",
524 true,
525 "ok",
526 json!({"plan_id":"plan-001"}),
527 vec!["using cached descriptor".to_string()],
528 Vec::new(),
529 )
530 .expect("script-stable envelope should build");
531 assert_eq!(envelope.schema_version, "command-envelope-v1");
532 assert!(envelope.ok);
533 assert_eq!(envelope.errors.len(), 0);
534 assert_eq!(envelope.warnings.len(), 1);
535 }
536
537 #[test]
538 fn actionable_error_envelope_includes_remediation_and_evidence_pointer() {
539 let error = build_actionable_error_envelope(
540 ActionableFailureClassV1::Plugin,
541 "plugin_manifest_invalid",
542 "plugin manifest rejected",
543 "run `bijux plugins inspect <name>` and correct manifest fields",
544 Some("artifacts/cli/errors/plugin-manifest-invalid.log"),
545 )
546 .expect("actionable error should build");
547 assert_eq!(error.code, "plugin_manifest_invalid");
548 assert_eq!(
549 error.evidence_pointer.as_deref(),
550 Some("artifacts/cli/errors/plugin-manifest-invalid.log")
551 );
552 }
553
554 #[test]
555 fn command_explain_includes_route_and_side_effect_contract() {
556 let record = build_command_explain_record(
557 "bijux dag run",
558 "dag.runtime.run",
559 "official-app",
560 "dag-run-envelope-v1",
561 "writes-run",
562 &["dag.run_root", "dag.cache_root"],
563 )
564 .expect("explain contract should build");
565 assert_eq!(record.route_target, "dag.runtime.run");
566 assert_eq!(record.handler_source, "official-app");
567 assert_eq!(record.required_config_keys, vec!["dag.cache_root", "dag.run_root"]);
568 }
569
570 #[test]
571 fn output_mode_parity_reports_commands_missing_required_modes() {
572 let report = evaluate_output_mode_parity(vec![
573 OutputModeParityEntryV1 {
574 command: "bijux dag run".to_string(),
575 supported_modes: vec![
576 "human".to_string(),
577 "json".to_string(),
578 "jsonl".to_string(),
579 "artifact-output".to_string(),
580 ],
581 },
582 OutputModeParityEntryV1 {
583 command: "bijux doctor".to_string(),
584 supported_modes: vec!["human".to_string(), "json".to_string()],
585 },
586 ]);
587 assert!(!report.parity_complete);
588 assert_eq!(report.missing_mode_commands, vec!["bijux doctor"]);
589 }
590
591 #[test]
592 fn install_diagnostics_bundle_identifies_failing_component() {
593 let bundle = build_install_diagnosis_bundle(vec![
594 InstallDiagnosticComponentV1 {
595 component: "binary_path".to_string(),
596 healthy: true,
597 detail: "resolved /usr/local/bin/bijux".to_string(),
598 },
599 InstallDiagnosticComponentV1 {
600 component: "python_bridge".to_string(),
601 healthy: false,
602 detail: "missing importable python bridge package".to_string(),
603 },
604 ])
605 .expect("diagnosis bundle should build");
606 assert!(!bundle.healthy_install);
607 assert_eq!(bundle.failing_components, vec!["python_bridge"]);
608 }
609
610 #[test]
611 fn completion_snapshot_excludes_hidden_stale_and_deprecated_routes() {
612 let snapshot = build_completion_snapshot_from_registry(
613 "zsh",
614 vec![
615 CompletionRouteEntryV1 {
616 command: "bijux dag run".to_string(),
617 hidden: false,
618 deprecated: false,
619 stale: false,
620 },
621 CompletionRouteEntryV1 {
622 command: "bijux dag old-run".to_string(),
623 hidden: false,
624 deprecated: true,
625 stale: false,
626 },
627 CompletionRouteEntryV1 {
628 command: "bijux secret route".to_string(),
629 hidden: true,
630 deprecated: false,
631 stale: false,
632 },
633 ],
634 false,
635 )
636 .expect("completion snapshot should build");
637 assert_eq!(snapshot.commands, vec!["bijux dag run"]);
638 }
639
640 #[test]
641 fn side_effect_classification_marks_risky_dispatches() {
642 let preview =
643 classify_command_side_effect("bijux dag run --graph sample.json").expect("classify");
644 assert!(preview.requires_confirmation);
645 assert_eq!(preview.command, "bijux dag run --graph sample.json");
646 }
647
648 #[test]
649 fn python_bridge_parity_report_detects_machine_output_drift() {
650 let report = build_python_bridge_command_parity_report(vec![
651 PythonBridgeParityEntryV1 {
652 command: "bijux status --format json".to_string(),
653 rust_machine_output: "{\"ok\":true}".to_string(),
654 python_machine_output: "{\"ok\":true}".to_string(),
655 },
656 PythonBridgeParityEntryV1 {
657 command: "bijux doctor --format json".to_string(),
658 rust_machine_output: "{\"ok\":false,\"code\":\"doctor_warn\"}".to_string(),
659 python_machine_output: "{\"ok\":false,\"code\":\"doctor_warning\"}".to_string(),
660 },
661 ]);
662 assert!(!report.parity_exact);
663 assert_eq!(report.mismatched_commands, vec!["bijux doctor --format json"]);
664 }
665
666 #[test]
667 fn official_app_discovery_prefers_highest_priority_and_refuses_shadow_attempts() {
668 let report = build_official_app_discovery_report(
669 vec![
670 OfficialAppRouteDescriptorV1 {
671 namespace: "dag".to_string(),
672 descriptor_id: "dag-v1".to_string(),
673 priority: 10,
674 },
675 OfficialAppRouteDescriptorV1 {
676 namespace: "dag".to_string(),
677 descriptor_id: "dag-v2".to_string(),
678 priority: 20,
679 },
680 ],
681 vec!["plugin:community attempted dag".to_string()],
682 vec!["shim:bijux-dag attempted dag".to_string()],
683 );
684 assert_eq!(report.winners.len(), 1);
685 assert_eq!(report.winners[0].descriptor_id, "dag-v2");
686 assert_eq!(report.refused_plugin_shadows, vec!["plugin:community attempted dag"]);
687 }
688}