1mod delegation;
4mod help;
5mod policy;
6mod route_exec;
7mod suggest;
8
9use anyhow::Result;
10use serde_json::json;
11
12use crate::contracts::known_bijux_tools;
13use crate::contracts::OutputFormat;
14use crate::interface::cli::handlers::install as install_handler;
15use crate::interface::cli::help::{render_command_help, render_known_tool_help};
16use crate::interface::cli::parser::parse_intent;
17use crate::routing::model::{alias_rewrites, built_in_route_paths};
18use crate::shared::output::render_value;
19use crate::shared::telemetry::{
20 truncate_chars, TelemetrySpan, MAX_COMMAND_FIELD_CHARS, MAX_TEXT_FIELD_CHARS,
21};
22
23const MAX_PATH_FIELD_SEGMENTS: usize = 32;
24const MAX_PATH_SEGMENT_CHARS: usize = 128;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct AppRunResult {
29 pub exit_code: i32,
31 pub stdout: String,
33 pub stderr: String,
35}
36
37fn root_usage_help_text() -> Result<String> {
38 let help_argv = vec!["bijux".to_string(), "--help".to_string()];
39 if let Some(help) = help::try_render_clap_help(&help_argv) {
40 return Ok(help);
41 }
42
43 Ok(format!("{}\n", render_command_help(&[])?.trim_end()))
44}
45
46fn bounded_command(command: &str) -> (String, bool) {
47 truncate_chars(command, MAX_COMMAND_FIELD_CHARS)
48}
49
50fn bounded_message(message: &str) -> (String, bool) {
51 truncate_chars(message, MAX_TEXT_FIELD_CHARS)
52}
53
54fn bounded_status(status: Option<&str>) -> (Option<String>, bool) {
55 match status {
56 Some(value) => {
57 let (bounded, truncated) = bounded_message(value);
58 (Some(bounded), truncated)
59 }
60 None => (None, false),
61 }
62}
63
64fn bounded_segments(path: &[String]) -> (Vec<String>, usize, usize) {
65 let mut bounded = Vec::with_capacity(path.len().min(MAX_PATH_FIELD_SEGMENTS));
66 let mut truncated_segment_count = 0usize;
67
68 for segment in path.iter().take(MAX_PATH_FIELD_SEGMENTS) {
69 let (value, truncated) = truncate_chars(segment, MAX_PATH_SEGMENT_CHARS);
70 bounded.push(value);
71 if truncated {
72 truncated_segment_count += 1;
73 }
74 }
75
76 let clipped_segment_count = path.len().saturating_sub(MAX_PATH_FIELD_SEGMENTS);
77 (bounded, truncated_segment_count, clipped_segment_count)
78}
79
80fn levenshtein_distance(left: &str, right: &str) -> usize {
81 if left.is_empty() {
82 return right.chars().count();
83 }
84 if right.is_empty() {
85 return left.chars().count();
86 }
87
88 let left_chars = left.chars().collect::<Vec<_>>();
89 let right_chars = right.chars().collect::<Vec<_>>();
90 let mut prev = (0..=right_chars.len()).collect::<Vec<usize>>();
91 let mut curr = vec![0usize; right_chars.len() + 1];
92
93 for (i, left_ch) in left_chars.iter().enumerate() {
94 curr[0] = i + 1;
95 for (j, right_ch) in right_chars.iter().enumerate() {
96 let substitution_cost = usize::from(left_ch != right_ch);
97 curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + substitution_cost);
98 }
99 std::mem::swap(&mut prev, &mut curr);
100 }
101
102 prev[right_chars.len()]
103}
104
105fn known_help_topics() -> Vec<String> {
106 let mut topics = built_in_route_paths().to_vec();
107 topics.extend(alias_rewrites().iter().map(|(alias, _)| (*alias).to_string()));
108 topics.extend(known_bijux_tools().iter().map(|tool| tool.namespace.to_string()));
109 for tool in known_bijux_tools() {
110 topics.extend(tool.aliases.iter().map(|alias| (*alias).to_string()));
111 }
112 topics.push("help".to_string());
113 let mut expanded = Vec::new();
114 for topic in topics {
115 let mut prefix = Vec::new();
116 for segment in topic.split_whitespace() {
117 prefix.push(segment);
118 expanded.push(prefix.join(" "));
119 }
120 }
121 expanded.sort();
122 expanded.dedup();
123 expanded
124}
125
126fn is_known_help_topic(path: &[String]) -> bool {
127 if path.is_empty() {
128 return true;
129 }
130 let requested = path.join(" ").to_ascii_lowercase();
131 known_help_topics().iter().any(|candidate| candidate.eq_ignore_ascii_case(requested.as_str()))
132}
133
134fn suggest_help_topics(requested: &str) -> Vec<String> {
135 let requested = requested.trim().to_ascii_lowercase();
136 if requested.is_empty() {
137 return Vec::new();
138 }
139
140 let mut scored = Vec::new();
141 for candidate in known_help_topics() {
142 let normalized = candidate.to_ascii_lowercase();
143 if normalized == requested {
144 continue;
145 }
146 let prefix_match =
147 normalized.starts_with(&requested) || requested.starts_with(normalized.as_str());
148 let distance = levenshtein_distance(&requested, &normalized);
149 let threshold = (requested.chars().count().max(normalized.chars().count()) / 3).max(2);
150 if prefix_match || distance <= threshold {
151 scored.push((!prefix_match, distance, normalized.len(), candidate));
152 }
153 }
154
155 scored.sort();
156 scored.into_iter().map(|(_, _, _, candidate)| candidate).take(3).collect()
157}
158
159fn unknown_help_topic_result(requested: &str, telemetry: &TelemetrySpan) -> AppRunResult {
160 let suggestions = suggest_help_topics(requested);
161 let (requested_bounded, requested_truncated) = bounded_command(requested);
162 telemetry.record(
163 "dispatch.help.unknown_topic",
164 json!({
165 "requested": requested_bounded,
166 "requested_truncated": requested_truncated,
167 "suggestions_count": suggestions.len(),
168 "exit_code": 2,
169 }),
170 );
171 let mut stderr = format!("Unknown help topic: {requested}.\n");
172 if !suggestions.is_empty() {
173 stderr.push_str("Did you mean:\n");
174 for suggestion in suggestions {
175 stderr.push_str(&format!(" bijux help {suggestion}\n"));
176 }
177 }
178 stderr.push_str("Run `bijux --help` for available runtime commands.\n");
179 AppRunResult { exit_code: 2, stdout: String::new(), stderr }
180}
181
182pub fn run_app(argv: &[String]) -> Result<AppRunResult> {
184 let telemetry = TelemetrySpan::start("bijux-cli", argv);
185 telemetry.record("dispatch.entry", json!({"argv_count": argv.len()}));
186 let result = run_app_inner(argv, &telemetry);
187 match &result {
188 Ok(value) => telemetry.finish_exit(value.exit_code, value.stdout.len(), value.stderr.len()),
189 Err(error) => telemetry.finish_internal_error(&error.to_string(), 1),
190 }
191 result
192}
193
194fn run_app_inner(argv: &[String], telemetry: &TelemetrySpan) -> Result<AppRunResult> {
195 if argv.len() == 1 {
196 telemetry.record("dispatch.help.default", json!({"reason":"no_args"}));
197 let help_text = match root_usage_help_text() {
198 Ok(help) => help,
199 Err(error) => {
200 let (message, message_truncated) = bounded_message(&error.to_string());
201 telemetry.record(
202 "dispatch.help.render.error",
203 json!({"message": message, "message_truncated": message_truncated}),
204 );
205 return Err(error);
206 }
207 };
208 telemetry.record("dispatch.help.rendered", json!({"topic":"root", "exit_code": 0}));
209 return Ok(AppRunResult {
210 exit_code: 0,
211 stdout: format!("{}\n", help_text.trim_end()),
212 stderr: String::new(),
213 });
214 }
215
216 if argv.len() == 2 && matches!(argv[1].as_str(), "--version" | "-V") {
217 let (flag, flag_truncated) = bounded_command(&argv[1]);
218 telemetry.record(
219 "dispatch.version.alias",
220 json!({"flag": flag, "flag_truncated": flag_truncated}),
221 );
222 let normalized = vec![argv[0].clone(), "version".to_string()];
223 return run_app_inner(&normalized, telemetry);
224 }
225
226 if argv.len() >= 2 && argv[1] == "help" {
227 let path = match help::parse_help_command_path(argv) {
228 Ok(path) => path,
229 Err(message) => {
230 let (bounded, message_truncated) = bounded_message(&message);
231 telemetry.record(
232 "dispatch.help.error",
233 json!({"message": bounded, "message_truncated": message_truncated, "exit_code": 2}),
234 );
235 let mut stderr = message;
236 stderr.push('\n');
237 stderr.push_str("Run `bijux --help` for available runtime commands.\n");
238 return Ok(AppRunResult { exit_code: 2, stdout: String::new(), stderr });
239 }
240 };
241 let path_refs: Vec<&str> = path.iter().map(String::as_str).collect();
242 if let Some(rendered) = render_known_tool_help(&path_refs) {
243 let topic = path.join(" ");
244 let (topic_bounded, topic_truncated) = bounded_command(&topic);
245 telemetry.record(
246 "dispatch.help.rendered",
247 json!({
248 "topic": topic_bounded,
249 "topic_truncated": topic_truncated,
250 "exit_code": 0,
251 }),
252 );
253 return Ok(AppRunResult {
254 exit_code: 0,
255 stdout: format!("{}\n", rendered.trim_end()),
256 stderr: String::new(),
257 });
258 }
259 if !is_known_help_topic(&path) {
260 return Ok(unknown_help_topic_result(&path_refs.join(" "), telemetry));
261 }
262 let rendered = match render_command_help(&path_refs) {
263 Ok(rendered) => rendered,
264 Err(_) => return Ok(unknown_help_topic_result(&path_refs.join(" "), telemetry)),
265 };
266 let topic = if path.is_empty() { "root".to_string() } else { path.join(" ") };
267 let (topic_bounded, topic_truncated) = bounded_command(&topic);
268 telemetry.record(
269 "dispatch.help.rendered",
270 json!({
271 "topic": topic_bounded,
272 "topic_truncated": topic_truncated,
273 "exit_code": 0,
274 }),
275 );
276 return Ok(AppRunResult {
277 exit_code: 0,
278 stdout: format!("{}\n", rendered.trim_end()),
279 stderr: String::new(),
280 });
281 }
282
283 let has_help_flag = argv.iter().any(|arg| matches!(arg.as_str(), "--help" | "-h"));
284 if has_help_flag && delegation::is_known_bijux_tool_route(&argv[1..]) {
285 if let Some(delegated) = delegation::try_delegate_known_bijux_tool(argv) {
286 let surface = delegation::delegated_command_surface(argv).unwrap_or_default();
287 let (target, target_truncated) = bounded_command(&surface);
288 telemetry.record(
289 "dispatch.delegated.help_flag",
290 json!({"target": target, "target_truncated": target_truncated, "exit_code": delegated.exit_code}),
291 );
292 return Ok(delegated);
293 }
294 }
295
296 if let Some(help) = help::try_render_clap_help(argv) {
297 telemetry.record(
298 "dispatch.clap.short_circuit",
299 json!({"kind":"help_or_version", "exit_code": 0}),
300 );
301 return Ok(AppRunResult { exit_code: 0, stdout: help, stderr: String::new() });
302 }
303
304 if let Some(delegated) = delegation::try_delegate_known_bijux_tool(argv) {
305 let surface = delegation::delegated_command_surface(argv).unwrap_or_default();
306 let (target, target_truncated) = bounded_command(&surface);
307 telemetry.record(
308 "dispatch.delegated.command",
309 json!({"target": target, "target_truncated": target_truncated, "exit_code": delegated.exit_code}),
310 );
311 return Ok(delegated);
312 }
313
314 if let Some(usage_error) = help::try_render_clap_usage_error(argv) {
315 telemetry
316 .record("dispatch.clap.short_circuit", json!({"kind":"usage_error", "exit_code": 2}));
317 return Ok(AppRunResult {
318 exit_code: 2,
319 stdout: String::new(),
320 stderr: if usage_error.ends_with('\n') {
321 usage_error
322 } else {
323 format!("{usage_error}\n")
324 },
325 });
326 }
327
328 let intent = match parse_intent(argv) {
329 Ok(intent) => intent,
330 Err(error) => {
331 let (message, message_truncated) = bounded_message(&error.to_string());
332 telemetry.record(
333 "dispatch.intent.error",
334 json!({"message": message, "message_truncated": message_truncated, "exit_code": 2}),
335 );
336 return Ok(AppRunResult {
337 exit_code: 2,
338 stdout: String::new(),
339 stderr: format!("{error}\n"),
340 });
341 }
342 };
343 let (command_path, command_path_truncated_segment_count, command_path_clipped_segment_count) =
344 bounded_segments(&intent.command_path);
345 let (
346 normalized_path,
347 normalized_path_truncated_segment_count,
348 normalized_path_clipped_segment_count,
349 ) = bounded_segments(&intent.normalized_path);
350 telemetry.record(
351 "dispatch.intent.parsed",
352 json!({
353 "command_path": command_path,
354 "command_path_truncated_segment_count": command_path_truncated_segment_count,
355 "command_path_clipped_segment_count": command_path_clipped_segment_count,
356 "normalized_path": normalized_path,
357 "normalized_path_truncated_segment_count": normalized_path_truncated_segment_count,
358 "normalized_path_clipped_segment_count": normalized_path_clipped_segment_count,
359 "quiet": intent.global_flags.quiet,
360 }),
361 );
362 let emitter_config = policy::emitter_config(&intent.global_flags);
363 if intent.normalized_path.is_empty() {
364 telemetry.record("dispatch.intent.empty", json!({}));
365 let usage = match root_usage_help_text() {
366 Ok(value) => value,
367 Err(error) => {
368 let (message, message_truncated) = bounded_message(&error.to_string());
369 telemetry.record(
370 "dispatch.help.render.error",
371 json!({"message": message, "message_truncated": message_truncated}),
372 );
373 return Err(error);
374 }
375 };
376 return Ok(AppRunResult { exit_code: 2, stdout: String::new(), stderr: usage });
377 }
378
379 if let Some(result) =
380 install_handler::try_run(&intent.normalized_path, argv, &intent.global_flags)?
381 {
382 let command_joined = intent.normalized_path.join(" ");
383 let (command, command_truncated) = bounded_command(&command_joined);
384 telemetry.record(
385 "dispatch.route.completed",
386 json!({
387 "command": command,
388 "command_truncated": command_truncated,
389 "status": if result.exit_code == 0 { Some("ok") } else { Some("error") },
390 "status_truncated": false,
391 "exit_code": result.exit_code,
392 "exit_kind": crate::shared::telemetry::exit_code_kind(result.exit_code),
393 }),
394 );
395 return Ok(result);
396 }
397
398 let response = route_exec::route_response(&intent.normalized_path, argv, &intent.global_flags);
399 let payload = match response {
400 Ok(route_exec::RouteResponse::Payload(value)) => value,
401 Ok(route_exec::RouteResponse::Process(result)) => {
402 let command_joined = intent.normalized_path.join(" ");
403 let (command, command_truncated) = bounded_command(&command_joined);
404 telemetry.record(
405 "dispatch.route.completed",
406 json!({
407 "command": command,
408 "command_truncated": command_truncated,
409 "status": if result.exit_code == 0 { Some("ok") } else { Some("error") },
410 "status_truncated": false,
411 "exit_code": result.exit_code,
412 "exit_kind": crate::shared::telemetry::exit_code_kind(result.exit_code),
413 }),
414 );
415 return Ok(result);
416 }
417 Err(error) => {
418 let message = error.to_string();
419 let code = policy::classify_error_exit_code(&message);
420 let command_joined = intent.normalized_path.join(" ");
421 let (command, command_truncated) = bounded_command(&command_joined);
422 let (message_bounded, message_truncated) = bounded_message(&message);
423 telemetry.record(
424 "dispatch.route.error",
425 json!({
426 "command": command.clone(),
427 "command_truncated": command_truncated,
428 "exit_code": code,
429 "exit_kind": crate::shared::telemetry::exit_code_kind(code),
430 "message": message_bounded,
431 "message_truncated": message_truncated,
432 }),
433 );
434 let mut suggestion_emitted = false;
435 let mut error_payload = json!({
436 "status": "error",
437 "code": code,
438 "message": message,
439 "command": intent.normalized_path.join(" "),
440 });
441 if message.starts_with("unknown route: ") {
442 if let Some(correction) =
443 suggest::correction_for_unknown_route(&intent.normalized_path)
444 {
445 let nearest_command = correction.nearest_command;
446 let next_command = correction.next_command;
447 let next_help = correction.next_help;
448 let (nearest_command_bounded, nearest_command_truncated) =
449 bounded_command(&nearest_command);
450 let (next_command_bounded, next_command_truncated) =
451 bounded_command(&next_command);
452 let (next_help_bounded, next_help_truncated) = bounded_command(&next_help);
453 error_payload["nearest_command"] = json!(nearest_command);
454 error_payload["next_command"] = json!(next_command.clone());
455 error_payload["next_help"] = json!(next_help.clone());
456 error_payload["hint"] =
457 json!(format!("Try `{}` or `{}`.", next_command, next_help));
458 suggestion_emitted = true;
459 telemetry.record(
460 "dispatch.route.suggested",
461 json!({
462 "command": command,
463 "command_truncated": command_truncated,
464 "nearest_command": nearest_command_bounded,
465 "nearest_command_truncated": nearest_command_truncated,
466 "next_command": next_command_bounded,
467 "next_command_truncated": next_command_truncated,
468 "next_help": next_help_bounded,
469 "next_help_truncated": next_help_truncated,
470 "source": "error_path",
471 }),
472 );
473 }
474 }
475 if message.starts_with("unknown route: ") {
476 telemetry.record(
477 "dispatch.route.unknown",
478 json!({
479 "command": command.clone(),
480 "command_truncated": command_truncated,
481 "exit_code": code,
482 "exit_kind": crate::shared::telemetry::exit_code_kind(code),
483 "source": "error_path",
484 "suggestion_emitted": suggestion_emitted,
485 }),
486 );
487 }
488 let rendered_error = match render_value(&error_payload, emitter_config) {
489 Ok(value) => value,
490 Err(error) => {
491 let (message, message_truncated) = bounded_message(&error.to_string());
492 telemetry.record(
493 "dispatch.render.error",
494 json!({"stream":"stderr","message": message, "message_truncated": message_truncated}),
495 );
496 return Err(error.into());
497 }
498 };
499 let error_content = if rendered_error.ends_with('\n') {
500 rendered_error
501 } else {
502 format!("{rendered_error}\n")
503 };
504 return Ok(AppRunResult {
505 exit_code: code,
506 stdout: String::new(),
507 stderr: error_content,
508 });
509 }
510 };
511
512 let rendered = if matches!(intent.normalized_path.as_slice(), [a, b] if a == "cli" && b == "version")
513 && emitter_config.format == OutputFormat::Text
514 {
515 crate::api::version::runtime_version_line()
516 } else if matches!(intent.normalized_path.as_slice(), [a, b] if a == "cli" && b == "completion")
517 && emitter_config.format == OutputFormat::Text
518 {
519 payload
520 .get("script")
521 .and_then(serde_json::Value::as_str)
522 .map(ToOwned::to_owned)
523 .unwrap_or_default()
524 } else {
525 match render_value(&payload, emitter_config) {
526 Ok(value) => value,
527 Err(error) => {
528 let (message, message_truncated) = bounded_message(&error.to_string());
529 telemetry.record(
530 "dispatch.render.error",
531 json!({"stream":"stdout","message": message, "message_truncated": message_truncated}),
532 );
533 return Err(error.into());
534 }
535 }
536 };
537 let content = if rendered.ends_with('\n') { rendered } else { format!("{rendered}\n") };
538
539 let route_exit_code = 0;
540 let command_joined = intent.normalized_path.join(" ");
541 let (command, command_truncated) = bounded_command(&command_joined);
542 let (status, status_truncated) =
543 bounded_status(payload.get("status").and_then(serde_json::Value::as_str));
544 telemetry.record(
545 "dispatch.route.completed",
546 json!({
547 "command": command.clone(),
548 "command_truncated": command_truncated,
549 "status": status,
550 "status_truncated": status_truncated,
551 "exit_code": route_exit_code,
552 "exit_kind": crate::shared::telemetry::exit_code_kind(route_exit_code),
553 }),
554 );
555
556 if intent.global_flags.quiet {
557 telemetry.record(
558 "dispatch.quiet.suppressed",
559 json!({
560 "command": command,
561 "command_truncated": command_truncated,
562 "exit_code": route_exit_code,
563 "suppressed_stdout_bytes": content.len(),
564 "suppressed_stderr_bytes": 0,
565 }),
566 );
567 return Ok(AppRunResult {
568 exit_code: route_exit_code,
569 stdout: String::new(),
570 stderr: String::new(),
571 });
572 }
573
574 Ok(AppRunResult { exit_code: route_exit_code, stdout: content, stderr: String::new() })
575}
576
577#[cfg(test)]
578mod tests {
579 use serde_json::Value;
580
581 use super::{run_app, MAX_PATH_SEGMENT_CHARS};
582 use crate::shared::telemetry::{install_test_telemetry_config, TEST_ENV_LOCK};
583
584 #[test]
585 fn run_app_writes_opt_in_telemetry_events() {
586 let _guard = TEST_ENV_LOCK.lock().expect("env lock");
587 let temp = tempfile::tempdir().expect("temp dir");
588 let sink = temp.path().join("telemetry").join("events.jsonl");
589 let _telemetry = install_test_telemetry_config(Some(sink.clone()), false);
590
591 let result = run_app(&["bijux".to_string(), "status".to_string()]).expect("run");
592 assert_eq!(result.exit_code, 0);
593
594 let body = std::fs::read_to_string(&sink).expect("telemetry output");
595 let rows: Vec<Value> =
596 body.lines().map(|line| serde_json::from_str(line).expect("json")).collect();
597 assert!(
598 rows.iter().any(|row| row["stage"] == "invocation.start"),
599 "telemetry should include invocation.start"
600 );
601 assert!(
602 rows.iter().any(|row| row["stage"] == "invocation.finish"),
603 "telemetry should include invocation.finish"
604 );
605 assert!(rows.iter().all(|row| row["runtime"] == "bijux-cli"));
606 }
607
608 #[test]
609 fn run_app_unknown_route_emits_unknown_stage_without_completed_stage() {
610 let _guard = TEST_ENV_LOCK.lock().expect("env lock");
611 let temp = tempfile::tempdir().expect("temp dir");
612 let sink = temp.path().join("telemetry").join("events.jsonl");
613 let _telemetry = install_test_telemetry_config(Some(sink.clone()), false);
614
615 let result =
616 run_app(&["bijux".to_string(), "definitely-not-a-command".to_string()]).expect("run");
617 assert_eq!(result.exit_code, 2);
618
619 let body = std::fs::read_to_string(&sink).expect("telemetry output");
620 let rows: Vec<Value> =
621 body.lines().map(|line| serde_json::from_str(line).expect("json")).collect();
622 let unknown = rows
623 .iter()
624 .find(|row| row["stage"] == "dispatch.route.unknown")
625 .expect("unknown route event");
626 assert_eq!(unknown["payload"]["exit_kind"], "usage");
627 assert_eq!(unknown["payload"]["suggestion_emitted"], true);
628 assert_eq!(unknown["payload"]["source"], "error_path");
629 assert!(rows.iter().any(|row| row["stage"] == "dispatch.route.suggested"));
630 assert!(
631 !rows.iter().any(|row| row["stage"] == "dispatch.route.completed"),
632 "unknown routes must not be reported as completed"
633 );
634 }
635
636 #[test]
637 fn run_app_quiet_mode_records_suppressed_byte_metrics() {
638 let _guard = TEST_ENV_LOCK.lock().expect("env lock");
639 let temp = tempfile::tempdir().expect("temp dir");
640 let sink = temp.path().join("telemetry").join("events.jsonl");
641 let _telemetry = install_test_telemetry_config(Some(sink.clone()), false);
642
643 let result = run_app(&["bijux".to_string(), "status".to_string(), "--quiet".to_string()])
644 .expect("run");
645 assert_eq!(result.exit_code, 0);
646 assert!(result.stdout.is_empty());
647 assert!(result.stderr.is_empty());
648
649 let body = std::fs::read_to_string(&sink).expect("telemetry output");
650 let rows: Vec<Value> =
651 body.lines().map(|line| serde_json::from_str(line).expect("json")).collect();
652 let suppressed = rows
653 .iter()
654 .find(|row| row["stage"] == "dispatch.quiet.suppressed")
655 .expect("quiet suppressed event");
656 assert!(suppressed["payload"]["suppressed_stdout_bytes"].as_u64().unwrap_or_default() > 0);
657 assert_eq!(suppressed["payload"]["suppressed_stderr_bytes"], 0);
658 }
659
660 #[test]
661 fn run_app_bounds_intent_path_segments_in_telemetry() {
662 let _guard = TEST_ENV_LOCK.lock().expect("env lock");
663 let temp = tempfile::tempdir().expect("temp dir");
664 let sink = temp.path().join("telemetry").join("events.jsonl");
665 let _telemetry = install_test_telemetry_config(Some(sink.clone()), false);
666
667 let oversized = "x".repeat(MAX_PATH_SEGMENT_CHARS + 48);
668 let result = run_app(&["bijux".to_string(), oversized.clone()]).expect("run");
669 assert_eq!(result.exit_code, 2);
670
671 let body = std::fs::read_to_string(&sink).expect("telemetry output");
672 let rows: Vec<Value> =
673 body.lines().map(|line| serde_json::from_str(line).expect("json")).collect();
674 let parsed = rows
675 .iter()
676 .find(|row| row["stage"] == "dispatch.intent.parsed")
677 .expect("intent parsed event");
678 let first = parsed["payload"]["normalized_path"][0].as_str().expect("first segment");
679 assert_eq!(first.chars().count(), MAX_PATH_SEGMENT_CHARS);
680 assert_eq!(parsed["payload"]["normalized_path_truncated_segment_count"], 1);
681 }
682
683 #[test]
684 fn help_unknown_topic_emits_suggestions_for_near_matches() {
685 let result =
686 run_app(&["bijux".to_string(), "help".to_string(), "sttaus".to_string()]).expect("run");
687 assert_eq!(result.exit_code, 2);
688 assert!(result.stderr.contains("Unknown help topic: sttaus."));
689 assert!(result.stderr.contains("Did you mean:"));
690 assert!(result.stderr.contains("bijux help status"));
691 }
692
693 #[test]
694 fn levenshtein_distance_is_deterministic_for_help_suggestions() {
695 assert_eq!(super::levenshtein_distance("status", "status"), 0);
696 assert_eq!(super::levenshtein_distance("status", "sttaus"), 2);
697 }
698
699 #[test]
700 fn help_unknown_topic_suggests_root_alias_commands() {
701 let result = run_app(&["bijux".to_string(), "help".to_string(), "versoin".to_string()])
702 .expect("run");
703 assert_eq!(result.exit_code, 2);
704 assert!(result.stderr.contains("bijux help version"));
705 }
706
707 #[test]
708 fn default_help_matches_help_flag_surface() {
709 let no_args = run_app(&["bijux".to_string()]).expect("run without args");
710 let explicit = run_app(&["bijux".to_string(), "--help".to_string()]).expect("run --help");
711 assert_eq!(no_args.exit_code, 0);
712 assert_eq!(explicit.exit_code, 0);
713 assert_eq!(no_args.stdout, explicit.stdout);
714 assert_eq!(no_args.stderr, explicit.stderr);
715 }
716
717 #[test]
718 fn explain_reports_built_in_route_metadata() {
719 let result = run_app(&[
720 "bijux".to_string(),
721 "explain".to_string(),
722 "status".to_string(),
723 "--format".to_string(),
724 "json".to_string(),
725 ])
726 .expect("run");
727 assert_eq!(result.exit_code, 0);
728 let payload: Value = serde_json::from_str(result.stdout.trim()).expect("json");
729 assert_eq!(payload["status"], "ok");
730 assert_eq!(payload["route"]["target_class"], "built_in");
731 assert_eq!(payload["route"]["owner"], "bijux-cli");
732 assert_eq!(payload["route"]["descriptor_source"], "built-in route registry");
733 assert_eq!(payload["envelope"]["success_schema"], "output-envelope-v1");
734 }
735
736 #[test]
737 fn explain_reports_official_app_route_metadata() {
738 let result = run_app(&[
739 "bijux".to_string(),
740 "explain".to_string(),
741 "dag".to_string(),
742 "run".to_string(),
743 "--format".to_string(),
744 "json".to_string(),
745 ])
746 .expect("run");
747 assert_eq!(result.exit_code, 0);
748 let payload: Value = serde_json::from_str(result.stdout.trim()).expect("json");
749 assert_eq!(payload["status"], "ok");
750 assert_eq!(payload["route"]["target_class"], "official_app");
751 assert_eq!(payload["route"]["owner"], "dag");
752 assert_eq!(
753 payload["route"]["descriptor_source"],
754 "official_product_namespace_registry.json"
755 );
756 assert_eq!(payload["route"]["side_effect_class"], "external-exec");
757 }
758
759 #[test]
760 fn explain_reports_unknown_route_metadata() {
761 let result = run_app(&[
762 "bijux".to_string(),
763 "explain".to_string(),
764 "definitely-not-a-command".to_string(),
765 "--format".to_string(),
766 "json".to_string(),
767 ])
768 .expect("run");
769 assert_eq!(result.exit_code, 0);
770 let payload: Value = serde_json::from_str(result.stdout.trim()).expect("json");
771 assert_eq!(payload["status"], "error");
772 assert_eq!(payload["route"]["target_class"], "unknown");
773 }
774
775 #[test]
776 fn cli_script_contract_is_machine_readable() {
777 let result = run_app(&[
778 "bijux".to_string(),
779 "cli".to_string(),
780 "script-contract".to_string(),
781 "--format".to_string(),
782 "json".to_string(),
783 ])
784 .expect("run");
785 assert_eq!(result.exit_code, 0);
786 let payload: Value = serde_json::from_str(result.stdout.trim()).expect("json");
787 assert_eq!(payload["status"], "ok");
788 assert_eq!(payload["schema_version"], "bijux-cli-script-contract-v1");
789 assert_eq!(payload["safe_output_modes"][0], "json");
790 assert_eq!(payload["unsafe_for_parsing"][0], "text");
791 }
792
793 #[test]
794 fn cli_routes_exports_inventory_bundle() {
795 let result = run_app(&[
796 "bijux".to_string(),
797 "cli".to_string(),
798 "routes".to_string(),
799 "--format".to_string(),
800 "json".to_string(),
801 ])
802 .expect("run");
803 assert_eq!(result.exit_code, 0);
804 let payload: Value = serde_json::from_str(result.stdout.trim()).expect("json");
805 assert_eq!(payload["status"], "ok");
806 assert_eq!(payload["schema_version"], "bijux-cli-route-inventory-v1");
807 assert!(payload["inventory"]["builtins"].is_array());
808 assert!(payload["inventory"]["aliases"].is_array());
809 assert!(payload["inventory"]["namespaces"].is_array());
810 }
811
812 #[test]
813 fn cli_shims_reports_lifecycle_policy() {
814 let result = run_app(&[
815 "bijux".to_string(),
816 "cli".to_string(),
817 "shims".to_string(),
818 "--format".to_string(),
819 "json".to_string(),
820 ])
821 .expect("run");
822 assert_eq!(result.exit_code, 0);
823 let payload: Value = serde_json::from_str(result.stdout.trim()).expect("json");
824 assert_eq!(payload["lifecycle_policy"]["shim_support"], "deprecated");
825 assert_eq!(payload["lifecycle_policy"]["shadowing_policy"], "refused");
826 assert!(payload["legacy_app_shims"].is_array());
827 }
828}