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_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/// In-memory process output and exit result produced by the core app runner.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct AppRunResult {
29    /// Process exit code.
30    pub exit_code: i32,
31    /// Payload that should be written to stdout.
32    pub stdout: String,
33    /// Payload that should be written to stderr.
34    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
182/// Execute the CLI for provided argv and return output streams and exit code.
183pub 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}