Skip to main content

bijux_cli/interface/cli/
dispatch.rs

1//! Top-level application entrypoint and route execution.
2
3mod 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/// In-memory process output and exit result produced by the core app runner.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct AppRunResult {
28    /// Process exit code.
29    pub exit_code: i32,
30    /// Payload that should be written to stdout.
31    pub stdout: String,
32    /// Payload that should be written to stderr.
33    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
177/// Execute the CLI for provided argv and return output streams and exit code.
178pub 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}