返回 CodeWhale
approval.rs
根目录 / crates / tui / src / tui / approval.rs
1 //! Tool approval system for `DeepSeek` CLI.
2 //!
3 //! Hosts the [`ApprovalRequest`] / [`ApprovalView`] pair the engine asks
4 //! the TUI to present whenever a tool needs human approval, plus the
5 //! sandbox elevation flow ([`ElevationRequest`] / [`ElevationView`]) that
6 //! follows a sandbox denial.
7 //!
8 //! ## v0.6.7: Codex-style takeover with stakes-based variants (#129)
9 //!
10 //! The modal renders as a compact bottom-anchored approval card that preserves
11 //! transcript context and routes each request to one of two
12 //! stakes-based variants:
13 //!
14 //! - **Benign** (`RiskLevel::Benign`) — read-only ops, MCP discovery,
15 //! query-only network. A single `Enter` / `1` / `y` approves once;
16 //! `2` / `a` approves for the session.
17 //! - **Destructive** (`RiskLevel::Destructive`) — file writes, shell
18 //! commands that are not proven read-only, patches, MCP actions,
19 //! unclassified tools, and any "fetch arbitrary content" surface.
20 //! The approval card keeps the destructive badge and
21 //! impact summary visible, then lets `Enter` commit the highlighted
22 //! option or `y` / `a` / `d` commit directly.
23 //!
24 //! The decision events emitted upstream are unchanged
25 //! (`ViewEvent::ApprovalDecision`), so `ui.rs` and the engine handle
26 //! both variants without modification. Auto-approve / YOLO bypasses
27 //! happen *before* the view is constructed (see `tui/ui.rs`); this
28 //! module always assumes the user is being asked.
29
30 #[cfg(test)]
31 use crate::config::ApprovalDefaultSelection;
32 use crate::tools::approval_summary::{
33 delegated_authority_fields, param_preview, truncate_string_value,
34 };
35 use crate::tools::canonical_action::canonical_action_alias;
36 use codewhale_config::ToolAskRule;
37 use codewhale_localization::{Locale, MessageId, tr};
38 use serde_json::Value;
39 use std::path::Path;
40 use std::path::PathBuf;
41
42 #[cfg(test)]
43 use crate::sandbox::SandboxPolicy;
44 #[cfg(test)]
45 use crate::tui::views::{ModalView, ViewAction, ViewEvent};
46 #[cfg(test)]
47 use crossterm::event::KeyEvent;
48
49 mod ask_rules;
50 mod elevation;
51 pub mod policy;
52 mod previews;
53 mod view;
54
55 pub use ask_rules::PermissionRuleSavePreview;
56 #[cfg(test)]
57 use ask_rules::build_save_preview as build_permission_rule_save_preview;
58 use ask_rules::{
59 SAVE_PREVIEW_MAX_ENTRIES, build_persistent_allow_rules, build_persistent_ask_rules,
60 build_save_preview,
61 };
62 pub use elevation::{ElevationOption, ElevationRequest, ElevationView};
63 #[cfg(test)]
64 use previews::apply_patch_preview_lines;
65 pub(crate) use previews::format_shell_command_for_approval;
66 use previews::{
67 file_write_preview_lines, localize_detail_label, localize_preview_shell_line, param_text,
68 };
69 // Keep the existing module path even though production callers only construct
70 // `ApprovalView`; approval characterization tests inspect its typed options.
71 #[allow(unused_imports)]
72 pub use view::ApprovalOption;
73 pub use view::ApprovalView;
74
75 pub use policy::{
76 ApprovalStakes, RiskLevel, ToolCategory, classify_risk, classify_stakes,
77 get_tool_category_for_call,
78 };
79
80 /// User's decision for a pending approval
81 #[derive(Debug, Clone, PartialEq, Eq)]
82 pub enum ReviewDecision {
83 /// Execute this tool once
84 Approved,
85 /// Approve and don't ask again for this tool type this session
86 ApprovedForSession,
87 /// Reject the tool execution
88 Denied,
89 /// Abort the entire turn
90 Abort,
91 }
92
93 /// The agent a child approval card belongs to (approvals C1). `None` on the
94 /// parent's own cards.
95 #[derive(Debug, Clone, PartialEq, Eq)]
96 pub struct ApprovalOwner {
97 pub agent_id: String,
98 /// Stable user-facing agent label (`App::ensure_agent_label`).
99 pub label: String,
100 /// Fleet role label, when the roster knows the agent yet.
101 pub role: Option<String>,
102 }
103
104 /// Request for user approval of a tool execution
105 #[derive(Debug, Clone)]
106 pub struct ApprovalRequest {
107 /// Unique ID for this tool use
108 pub id: String,
109 /// Tool being executed
110 pub tool_name: String,
111 /// Human-readable tool description from the engine
112 pub description: String,
113 /// One plain sentence naming what the call does ("Search the web for
114 /// 'espresso'", "Write notes/espresso.md"), built from the tool name and
115 /// its arguments only (E6). The card leads with it.
116 pub summary: String,
117 /// Workspace the call runs in; card paths are shown relative to it.
118 pub workspace: PathBuf,
119 /// Tool category
120 pub category: ToolCategory,
121 /// Stakes-based routing for the compact approval card
122 pub risk: RiskLevel,
123 /// Derived impact summary for the approval prompt
124 pub impacts: Vec<String>,
125 /// Tool parameters (for display)
126 pub params: Value,
127 /// Exact-argument fingerprint, used to scope *denials* (#1617).
128 pub approval_key: String,
129 /// Lossy / arity-aware fingerprint, used to scope *approvals* so an
130 /// "approve for session" covers later flag variants (v0.8.37).
131 pub approval_grouping_key: String,
132 /// The model's explanation of intent before invoking write tools (#2381).
133 /// Displayed in the approval view so users understand *why* the change
134 /// is being made before reviewing *what* will change.
135 pub intent_summary: Option<String>,
136 /// Ask-only persistent rules that can be saved with the approval.
137 pub persistent_ask_rules: Vec<ToolAskRule>,
138 /// Exact repo-scoped allow rules available for safe approval requests.
139 pub persistent_allow_rules: Vec<ToolAskRule>,
140 /// The agent that raised this request, when it is a child's card.
141 pub owner: Option<ApprovalOwner>,
142 }
143
144 /// Key approval details rendered prominently in the approval card.
145 #[derive(Debug, Clone, PartialEq, Eq)]
146 pub struct ApprovalDetail {
147 pub label: String,
148 pub value: String,
149 /// Preformatted shell lines for commands that benefit from safe wrapping
150 /// or a compact write-file preview. `value` remains the original command.
151 pub shell_lines: Option<Vec<String>>,
152 }
153
154 impl ApprovalRequest {
155 /// Mechanical repo-law asks are a distinct authority boundary, not an
156 /// ordinary risk prompt. The engine stamps this stable prefix when a
157 /// `.codewhale/constitution.json` ask rule forces review.
158 #[must_use]
159 pub fn is_repo_law_prompt(&self) -> bool {
160 description_is_repo_law_prompt(&self.description)
161 }
162
163 /// Presentation stakes for this request (see [`ApprovalStakes`]).
164 #[must_use]
165 pub fn stakes(&self) -> ApprovalStakes {
166 classify_stakes(&self.tool_name, self.category, self.risk, &self.params)
167 }
168
169 #[cfg(test)]
170 pub fn new(
171 id: &str,
172 tool_name: &str,
173 description: &str,
174 params: &Value,
175 approval_key: &str,
176 ) -> Self {
177 Self::new_with_intent(
178 id,
179 tool_name,
180 description,
181 params,
182 approval_key,
183 None,
184 Path::new("/workspace"),
185 )
186 }
187
188 pub fn new_with_intent(
189 id: &str,
190 tool_name: &str,
191 description: &str,
192 params: &Value,
193 approval_key: &str,
194 intent_summary: Option<&str>,
195 workspace: &Path,
196 ) -> Self {
197 let semantic_tool_name = canonical_action_alias(tool_name, params);
198 let category = get_tool_category_for_call(tool_name, params);
199 let risk = classify_risk(tool_name, category, params);
200 let approval_grouping_key =
201 crate::tools::approval_cache::build_approval_grouping_key(tool_name, params).0;
202 let persistent_ask_rules =
203 build_persistent_ask_rules(semantic_tool_name, params, workspace);
204 let persistent_allow_rules = if classify_stakes(tool_name, category, risk, params)
205 == ApprovalStakes::Critical
206 || description_is_repo_law_prompt(description)
207 {
208 Vec::new()
209 } else {
210 build_persistent_allow_rules(
211 semantic_tool_name,
212 params,
213 workspace,
214 &persistent_ask_rules,
215 )
216 };
217
218 Self {
219 id: id.to_string(),
220 tool_name: tool_name.to_string(),
221 description: description.to_string(),
222 summary: crate::tools::approval_summary::approval_summary(
223 tool_name,
224 params,
225 Some(workspace),
226 ),
227 workspace: workspace.to_path_buf(),
228 category,
229 risk,
230 impacts: build_impact_summary(semantic_tool_name, category, params),
231 params: params.clone(),
232 approval_key: approval_key.to_string(),
233 approval_grouping_key,
234 intent_summary: intent_summary.and_then(|summary| {
235 let summary = summary.trim();
236 if summary.is_empty() {
237 None
238 } else {
239 Some(summary.to_string())
240 }
241 }),
242 persistent_ask_rules,
243 persistent_allow_rules,
244 owner: None,
245 }
246 }
247
248 /// Format parameters for display (truncated)
249 pub fn params_display(&self) -> String {
250 let truncated = truncate_params_value(&self.params, 200);
251 serde_json::to_string(&truncated).unwrap_or_else(|_| truncated.to_string())
252 }
253
254 /// The plain summary in `locale` (E6, experience mark 4): the same
255 /// sentence the English card leads with, translated around the verbatim
256 /// command, path or query.
257 #[must_use]
258 pub fn summary_for_locale(&self, locale: Locale) -> String {
259 if locale == Locale::En {
260 return self.summary.clone();
261 }
262 crate::tools::approval_summary::approval_summary_in(
263 locale,
264 &self.tool_name,
265 &self.params,
266 Some(&self.workspace),
267 )
268 }
269
270 pub fn description_for_locale(&self, locale: Locale) -> String {
271 match locale {
272 Locale::ZhHans => localized_description_zh_hans(self.category),
273 _ if self.category == ToolCategory::Shell => {
274 "Review the command before it runs.".to_string()
275 }
276 _ => self.description.clone(),
277 }
278 }
279
280 pub fn impacts_for_locale(&self, locale: Locale) -> Vec<String> {
281 let semantic_tool_name = canonical_action_alias(&self.tool_name, &self.params);
282 match locale {
283 Locale::ZhHans => {
284 build_impact_summary_zh_hans(semantic_tool_name, self.category, &self.params)
285 }
286 _ => self.impacts.clone(),
287 }
288 }
289
290 #[must_use]
291 pub fn can_save_ask_rule(&self) -> bool {
292 !self.persistent_ask_rules.is_empty()
293 }
294
295 #[must_use]
296 pub fn can_save_allow_rule(&self) -> bool {
297 !self.persistent_allow_rules.is_empty()
298 && self.stakes() != ApprovalStakes::Critical
299 && !self.is_repo_law_prompt()
300 }
301
302 #[must_use]
303 pub fn ask_rule_save_preview(&self) -> Option<PermissionRuleSavePreview> {
304 build_save_preview(&self.persistent_ask_rules, SAVE_PREVIEW_MAX_ENTRIES)
305 }
306
307 #[must_use]
308 pub fn allow_rule_save_preview(&self) -> Option<PermissionRuleSavePreview> {
309 self.can_save_allow_rule().then(|| {
310 build_save_preview(&self.persistent_allow_rules, SAVE_PREVIEW_MAX_ENTRIES)
311 .expect("eligible allow rules are non-empty")
312 })
313 }
314
315 #[must_use]
316 #[cfg(test)]
317 pub fn ask_rule_preview(&self) -> Option<String> {
318 if self.persistent_ask_rules.is_empty() {
319 return None;
320 }
321 let permissions = codewhale_config::PermissionsToml {
322 rules: self.persistent_ask_rules.clone(),
323 };
324 toml::to_string_pretty(&permissions).ok()
325 }
326
327 /// Extract the most important params for the approval card.
328 #[must_use]
329 pub fn prominent_detail_items(&self, locale: Locale) -> Vec<ApprovalDetail> {
330 let semantic_tool_name = canonical_action_alias(&self.tool_name, &self.params);
331 build_prominent_details(semantic_tool_name, self.category, &self.params)
332 .into_iter()
333 .map(|mut detail| {
334 if matches!(detail.label.as_str(), "File" | "Path" | "Dir") {
335 detail.value = workspace_relative(&detail.value, &self.workspace);
336 }
337 let is_preview = detail.label == "Preview";
338 detail.label = localize_detail_label(&detail.label, locale).to_string();
339 if is_preview && let Some(lines) = detail.shell_lines.as_mut() {
340 for line in lines.iter_mut() {
341 *line = localize_preview_shell_line(semantic_tool_name, line, locale)
342 .to_string();
343 }
344 detail.value = lines.join("\n");
345 }
346 detail
347 })
348 .collect()
349 }
350 }
351
352 /// Show `value` relative to `workspace` when it is an absolute path inside
353 /// it, so the card never spends a row on the workspace prefix.
354 fn workspace_relative(value: &str, workspace: &Path) -> String {
355 let path = Path::new(value);
356 if workspace.as_os_str().is_empty() || !path.is_absolute() {
357 return value.to_string();
358 }
359 match path.strip_prefix(workspace) {
360 Ok(relative) if relative.as_os_str().is_empty() => ".".to_string(),
361 Ok(relative) => relative.display().to_string(),
362 Err(_) => value.to_string(),
363 }
364 }
365
366 pub use crate::mcp::connected_app_server;
367
368 fn description_is_repo_law_prompt(description: &str) -> bool {
369 description.starts_with("Repo law holds this write:")
370 && description.contains(".codewhale/constitution.json")
371 }
372
373 fn mcp_target_hint(tool_name: &str) -> Option<String> {
374 let remainder = tool_name.strip_prefix("mcp_")?;
375 if remainder.is_empty() {
376 None
377 } else {
378 Some(remainder.to_string())
379 }
380 }
381
382 fn build_impact_summary(tool_name: &str, category: ToolCategory, params: &Value) -> Vec<String> {
383 let mut impacts = match category {
384 ToolCategory::Safe => {
385 let mut impacts = vec!["Read-only operation.".to_string()];
386 if let Some(path) = param_preview(params, &["path", "ref_id", "uri"], 72) {
387 impacts.push(format!("Reads: {path}"));
388 }
389 impacts
390 }
391 ToolCategory::FileWrite => {
392 let mut impacts =
393 vec!["Writes files in the workspace or an approved write scope.".to_string()];
394 if let Some(path) = param_preview(params, &["path", "target", "destination"], 72) {
395 impacts.push(format!("Writes: {path}"));
396 }
397 impacts
398 }
399 ToolCategory::Shell => {
400 vec!["Runs a shell command in your workspace.".to_string()]
401 }
402 ToolCategory::Network => {
403 let mut impacts = vec!["May reach network services or remote content.".to_string()];
404 if let Some(target) =
405 param_preview(params, &["url", "q", "query", "location", "repo"], 96)
406 {
407 impacts.push(format!("Target: {target}"));
408 }
409 impacts
410 }
411 ToolCategory::McpRead => {
412 let mut impacts =
413 vec!["Reads from a connected app without an obvious local write.".to_string()];
414 if let Some(target) = mcp_target_hint(tool_name) {
415 impacts.push(format!("Connected app: {target}"));
416 }
417 impacts
418 }
419 ToolCategory::McpAction => {
420 let mut impacts =
421 vec!["Uses a connected app action that may have side effects.".to_string()];
422 if let Some(target) = mcp_target_hint(tool_name) {
423 impacts.push(format!("Connected app: {target}"));
424 }
425 impacts
426 }
427 ToolCategory::Agent if tool_name == "workflow" => {
428 // #4126: elevated Workflow plan card — goal, children, capability flags, budget.
429 crate::tools::workflow_plan_approval::analyze_workflow_plan_approval(params)
430 .approval_impacts()
431 }
432 ToolCategory::Agent => {
433 let mut impacts = vec![
434 "Starts or checks on an agent; the agent still asks for its own approvals."
435 .to_string(),
436 ];
437 if let Some(kind) = param_preview(params, &["type"], 40) {
438 impacts.push(format!("Agent type: {kind}"));
439 }
440 impacts
441 }
442 ToolCategory::Unknown => {
443 let mut impacts = vec![
444 "Tool is not classified. Review params carefully before approving.".to_string(),
445 ];
446 if let Some(target) = param_preview(
447 params,
448 &["path", "cmd", "command", "url", "q", "query", "ref_id"],
449 96,
450 ) {
451 impacts.push(format!("Primary input: {target}"));
452 }
453 impacts
454 }
455 };
456 impacts.extend(delegated_authority_impacts(tool_name, params, false));
457 impacts
458 }
459
460 fn localized_description_zh_hans(category: ToolCategory) -> String {
461 let locale = Locale::ZhHans;
462 match category {
463 ToolCategory::Safe => tr(locale, MessageId::ApprovalDescSafe).to_string(),
464 ToolCategory::FileWrite => tr(locale, MessageId::ApprovalDescFileWrite).to_string(),
465 ToolCategory::Shell => tr(locale, MessageId::ApprovalDescShell).to_string(),
466 ToolCategory::Network => tr(locale, MessageId::ApprovalDescNetwork).to_string(),
467 ToolCategory::McpRead => tr(locale, MessageId::ApprovalDescMcpRead).to_string(),
468 ToolCategory::McpAction => tr(locale, MessageId::ApprovalDescMcpAction).to_string(),
469 ToolCategory::Agent => tr(locale, MessageId::ApprovalDescAgent).to_string(),
470 ToolCategory::Unknown => tr(locale, MessageId::ApprovalDescUnknown).to_string(),
471 }
472 }
473
474 fn build_impact_summary_zh_hans(
475 tool_name: &str,
476 category: ToolCategory,
477 params: &Value,
478 ) -> Vec<String> {
479 let locale = Locale::ZhHans;
480 let mut impacts = match category {
481 ToolCategory::Safe => {
482 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactSafe).to_string()];
483 if let Some(path) = param_preview(params, &["path", "ref_id", "uri"], 72) {
484 impacts.push(format!("读取:{path}"));
485 }
486 impacts
487 }
488 ToolCategory::FileWrite => {
489 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactFileWrite).to_string()];
490 if let Some(path) = param_preview(params, &["path", "target", "destination"], 72) {
491 impacts.push(format!("写入:{path}"));
492 }
493 impacts
494 }
495 ToolCategory::Shell => {
496 vec![tr(locale, MessageId::ApprovalImpactShell).to_string()]
497 }
498 ToolCategory::Network => {
499 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactNetwork).to_string()];
500 if let Some(target) =
501 param_preview(params, &["url", "q", "query", "location", "repo"], 96)
502 {
503 impacts.push(format!("目标:{target}"));
504 }
505 impacts
506 }
507 ToolCategory::McpRead => {
508 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactMcpRead).to_string()];
509 if let Some(target) = mcp_target_hint(tool_name) {
510 impacts.push(format!("已连接应用:{target}"));
511 }
512 impacts
513 }
514 ToolCategory::McpAction => {
515 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactMcpAction).to_string()];
516 if let Some(target) = mcp_target_hint(tool_name) {
517 impacts.push(format!("已连接应用:{target}"));
518 }
519 impacts
520 }
521 ToolCategory::Agent => {
522 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactAgent).to_string()];
523 if let Some(kind) = param_preview(params, &["type"], 40) {
524 impacts.push(format!("子代理类型:{kind}"));
525 }
526 impacts
527 }
528 ToolCategory::Unknown => {
529 let mut impacts = vec![tr(locale, MessageId::ApprovalImpactUnknown).to_string()];
530 if let Some(target) = param_preview(
531 params,
532 &["path", "cmd", "command", "url", "q", "query", "ref_id"],
533 96,
534 ) {
535 impacts.push(format!("主要输入:{target}"));
536 }
537 impacts
538 }
539 };
540 impacts.extend(delegated_authority_impacts(tool_name, params, true));
541 impacts
542 }
543
544 /// Tools that hand work to a later, unattended run (a durable task or a
545 /// scheduled automation) and can ask for authority of their own.
546 fn delegated_authority_impacts(tool_name: &str, params: &Value, zh: bool) -> Vec<String> {
547 let separator = if zh { ":" } else { ": " };
548 delegated_authority_fields(tool_name, params, zh)
549 .into_iter()
550 .map(|(label, value)| format!("{label}{separator}{value}"))
551 .collect()
552 }
553
554 fn build_prominent_details(
555 tool_name: &str,
556 category: ToolCategory,
557 params: &Value,
558 ) -> Vec<ApprovalDetail> {
559 let mut details: Vec<ApprovalDetail> = delegated_authority_fields(tool_name, params, false)
560 .into_iter()
561 .map(|(label, value)| ApprovalDetail {
562 label,
563 value,
564 shell_lines: None,
565 })
566 .collect();
567 match category {
568 ToolCategory::Shell => {
569 if let Some(command) = param_text(params, &["command", "cmd"]) {
570 details.push(ApprovalDetail {
571 label: "Command".to_string(),
572 shell_lines: Some(format_shell_command_for_approval(&command)),
573 value: command,
574 });
575 }
576 if let Some(workdir) = param_preview(params, &["workdir", "cwd"], 96) {
577 details.push(ApprovalDetail {
578 label: "Dir".to_string(),
579 value: workdir,
580 shell_lines: None,
581 });
582 }
583 }
584 ToolCategory::FileWrite => {
585 if let Some(path) = param_preview(params, &["path", "target", "destination"], 200) {
586 details.push(ApprovalDetail {
587 label: "File".to_string(),
588 value: path,
589 shell_lines: None,
590 });
591 }
592 if let Some(preview_lines) = file_write_preview_lines(tool_name, params) {
593 details.push(ApprovalDetail {
594 label: "Preview".to_string(),
595 value: preview_lines.join("\n"),
596 shell_lines: Some(preview_lines),
597 });
598 }
599 }
600 ToolCategory::Safe => {
601 if let Some(path) = param_preview(params, &["path", "ref_id", "uri"], 200) {
602 details.push(ApprovalDetail {
603 label: "Path".to_string(),
604 value: path,
605 shell_lines: None,
606 });
607 }
608 }
609 ToolCategory::Network => {
610 if let Some(target) =
611 param_preview(params, &["url", "q", "query", "location", "repo"], 200)
612 {
613 details.push(ApprovalDetail {
614 label: "Target".to_string(),
615 value: target,
616 shell_lines: None,
617 });
618 }
619 }
620 ToolCategory::Agent if tool_name == "workflow" => {
621 // #4126: elevated Workflow plan card fields.
622 let summary =
623 crate::tools::workflow_plan_approval::analyze_workflow_plan_approval(params);
624 for (label, value) in summary.card_fields() {
625 details.push(ApprovalDetail {
626 label: label.to_string(),
627 value,
628 shell_lines: None,
629 });
630 }
631 }
632 ToolCategory::Agent => {
633 if let Some(action) = param_preview(params, &["action"], 40) {
634 details.push(ApprovalDetail {
635 label: "Action".to_string(),
636 value: action,
637 shell_lines: None,
638 });
639 }
640 if let Some(kind) = param_preview(params, &["type"], 40) {
641 details.push(ApprovalDetail {
642 label: "Type".to_string(),
643 value: kind,
644 shell_lines: None,
645 });
646 }
647 if let Some(prompt) = param_preview(params, &["prompt", "task", "message"], 200) {
648 details.push(ApprovalDetail {
649 label: "Prompt".to_string(),
650 value: prompt,
651 shell_lines: None,
652 });
653 }
654 }
655 ToolCategory::McpRead | ToolCategory::McpAction | ToolCategory::Unknown => {
656 if let Some(input) = param_preview(
657 params,
658 &["command", "cmd", "path", "url", "q", "query", "ref_id"],
659 200,
660 ) {
661 details.push(ApprovalDetail {
662 label: "Input".to_string(),
663 value: input,
664 shell_lines: None,
665 });
666 }
667 }
668 }
669 details
670 }
671
672 fn truncate_params_value(value: &Value, max_len: usize) -> Value {
673 match value {
674 Value::Object(map) => {
675 let truncated = map
676 .iter()
677 .map(|(key, val)| (key.clone(), truncate_params_value(val, max_len)))
678 .collect();
679 Value::Object(truncated)
680 }
681 Value::Array(items) => {
682 let truncated_items = items
683 .iter()
684 .map(|val| truncate_params_value(val, max_len))
685 .collect();
686 Value::Array(truncated_items)
687 }
688 Value::String(text) => Value::String(truncate_string_value(text, max_len)),
689 other => {
690 let rendered = other.to_string();
691 if rendered.chars().count() > max_len {
692 Value::String(truncate_string_value(&rendered, max_len))
693 } else {
694 other.clone()
695 }
696 }
697 }
698 }
699
700 // ============================================================================
701 // Tests
702 // ============================================================================
703
704 #[cfg(test)]
705 mod tests;
706
706 lines RUST