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