Skip to main content

intent_engine/cli_handlers/
other.rs

1// Note: CurrentAction and EventCommands removed in v0.10.1 CLI simplification
2// These functions are kept for potential Dashboard/MCP use but not exposed in CLI
3// use crate::cli::{CurrentAction, EventCommands};
4use crate::backend::{SearchBackend, TaskBackend};
5use crate::cli_handlers::read_stdin;
6use crate::cli_handlers::utils::{parse_status_keywords, parse_task_id_query};
7use crate::error::{IntentError, Result};
8use crate::events::EventManager;
9use crate::project::ProjectContext;
10use crate::report::ReportManager;
11use crate::time_utils::parse_date_filter;
12use crate::workspace::WorkspaceManager;
13use std::path::PathBuf;
14
15// Stub types for deprecated CLI commands (no longer in cli.rs)
16#[allow(dead_code)]
17pub enum CurrentAction {
18    Set { task_id: i64 },
19    Clear,
20}
21
22#[allow(dead_code)]
23pub enum EventCommands {
24    Add {
25        task_id: Option<i64>,
26        log_type: String,
27        data_stdin: bool,
28    },
29    List {
30        task_id: Option<i64>,
31        log_type: Option<String>,
32        since: Option<String>,
33        limit: Option<i64>,
34    },
35}
36
37pub async fn handle_current_command(
38    set: Option<i64>,
39    command: Option<CurrentAction>,
40) -> Result<()> {
41    let ctx = ProjectContext::load().await?;
42    let workspace_mgr = WorkspaceManager::new(&ctx.pool);
43
44    // Handle backward compatibility: --set flag takes precedence
45    if let Some(task_id) = set {
46        eprintln!("⚠️  Warning: 'ie current --set' is a low-level atomic command.");
47        eprintln!(
48            "   For normal use, prefer 'ie task start {}' which ensures data consistency.",
49            task_id
50        );
51        eprintln!();
52        let response = workspace_mgr.set_current_task(task_id, None).await?;
53        println!("✓ Switched to task #{}", task_id);
54        println!("{}", serde_json::to_string_pretty(&response)?);
55        return Ok(());
56    }
57
58    // Handle subcommands
59    match command {
60        Some(CurrentAction::Set { task_id }) => {
61            eprintln!("⚠️  Warning: 'ie current set' is a low-level atomic command.");
62            eprintln!(
63                "   For normal use, prefer 'ie task start {}' which ensures data consistency.",
64                task_id
65            );
66            eprintln!();
67            let response = workspace_mgr.set_current_task(task_id, None).await?;
68            println!("✓ Switched to task #{}", task_id);
69            println!("{}", serde_json::to_string_pretty(&response)?);
70        },
71        Some(CurrentAction::Clear) => {
72            eprintln!("⚠️  Warning: 'ie current clear' is a low-level atomic command.");
73            eprintln!("   For normal use, prefer 'ie task done' or 'ie task switch' which ensures data consistency.");
74            eprintln!();
75            workspace_mgr.clear_current_task(None).await?;
76            println!("✓ Current task cleared");
77        },
78        None => {
79            // Default: display current task in JSON format
80            let response = workspace_mgr.get_current_task(None).await?;
81            println!("{}", serde_json::to_string_pretty(&response)?);
82        },
83    }
84
85    Ok(())
86}
87
88pub async fn handle_report_command(
89    since: Option<String>,
90    status: Option<String>,
91    filter_name: Option<String>,
92    filter_spec: Option<String>,
93    summary_only: bool,
94) -> Result<()> {
95    let ctx = ProjectContext::load().await?;
96    let report_mgr = ReportManager::new(&ctx.pool);
97
98    let report = report_mgr
99        .generate_report(since, status, filter_name, filter_spec, summary_only)
100        .await?;
101    println!("{}", serde_json::to_string_pretty(&report)?);
102
103    Ok(())
104}
105
106pub async fn handle_event_command(cmd: EventCommands) -> Result<()> {
107    match cmd {
108        EventCommands::Add {
109            task_id,
110            log_type,
111            data_stdin,
112        } => {
113            let ctx = ProjectContext::load_or_init().await?;
114            let project_path = ctx.root.to_string_lossy().to_string();
115            let event_mgr = EventManager::with_project_path(&ctx.pool, project_path);
116
117            let data = if data_stdin {
118                read_stdin()?
119            } else {
120                return Err(IntentError::InvalidInput(
121                    "--data-stdin is required".to_string(),
122                ));
123            };
124
125            // Determine the target task ID
126            let target_task_id = if let Some(id) = task_id {
127                // Use the provided task_id
128                id
129            } else {
130                // Fall back to current_task_id from sessions table for this session
131                let session_id = crate::workspace::resolve_session_id(None);
132                let current_task_id: Option<i64> = sqlx::query_scalar::<_, Option<i64>>(
133                    "SELECT current_task_id FROM sessions WHERE session_id = ?",
134                )
135                .bind(&session_id)
136                .fetch_optional(&ctx.pool)
137                .await?
138                .flatten();
139
140                current_task_id
141                    .ok_or_else(|| IntentError::InvalidInput(
142                        "No current task is set and --task-id was not provided. Use 'current --set <ID>' to set a task first.".to_string(),
143                    ))?
144            };
145
146            let event = event_mgr.add_event(target_task_id, log_type, data).await?;
147            println!("{}", serde_json::to_string_pretty(&event)?);
148        },
149
150        EventCommands::List {
151            task_id,
152            limit,
153            log_type,
154            since,
155        } => {
156            let ctx = ProjectContext::load().await?;
157            let event_mgr = EventManager::new(&ctx.pool);
158
159            let events = event_mgr
160                .list_events(task_id, limit, log_type, since)
161                .await?;
162            println!("{}", serde_json::to_string_pretty(&events)?);
163        },
164    }
165
166    Ok(())
167}
168
169/// Safely truncate a UTF-8 string to a maximum number of characters
170/// Returns the truncated string with "..." appended if truncation occurred
171fn truncate_str(s: &str, max_chars: usize) -> String {
172    let char_count = s.chars().count();
173    if char_count <= max_chars {
174        s.to_string()
175    } else {
176        let truncated: String = s.chars().take(max_chars - 3).collect();
177        format!("{}...", truncated)
178    }
179}
180
181#[allow(clippy::too_many_arguments)]
182pub async fn handle_search_command(
183    task_mgr: &(impl TaskBackend + SearchBackend),
184    query: &str,
185    include_tasks: bool,
186    include_events: bool,
187    limit: Option<i64>,
188    offset: Option<i64>,
189    since: Option<String>,
190    until: Option<String>,
191    format: &str,
192) -> Result<()> {
193    use chrono::{DateTime, Utc};
194
195    // Parse date filters
196    let since_dt: Option<DateTime<Utc>> = if let Some(ref s) = since {
197        Some(parse_date_filter(s).map_err(IntentError::InvalidInput)?)
198    } else {
199        None
200    };
201
202    let until_dt: Option<DateTime<Utc>> = if let Some(ref u) = until {
203        Some(parse_date_filter(u).map_err(IntentError::InvalidInput)?)
204    } else {
205        None
206    };
207
208    // Check if query is a #ID format (e.g., "#123", "#1")
209    if let Some(task_id) = parse_task_id_query(query) {
210        match task_mgr.get_task(task_id).await {
211            Ok(task) => {
212                if format == "json" {
213                    println!("{}", serde_json::to_string_pretty(&task)?);
214                } else {
215                    let status_icon = match task.status.as_str() {
216                        "todo" => "○",
217                        "doing" => "●",
218                        "done" => "✓",
219                        _ => "?",
220                    };
221                    let parent_info = task
222                        .parent_id
223                        .map(|p| format!(" (parent: #{})", p))
224                        .unwrap_or_default();
225                    let priority_info = task
226                        .priority
227                        .map(|p| format!(" [P{}]", p))
228                        .unwrap_or_default();
229                    println!("Task #{}", task.id);
230                    println!(
231                        "  {} {}{}{}",
232                        status_icon, task.name, parent_info, priority_info
233                    );
234                    if let Some(spec) = &task.spec {
235                        if !spec.is_empty() {
236                            println!("  Spec: {}", spec);
237                        }
238                    }
239                    println!("  Owner: {}", task.owner);
240                    if let Some(ts) = task.first_todo_at {
241                        print!("  todo: {} ", ts.format("%Y-%m-%d %H:%M:%S"));
242                    }
243                    if let Some(ts) = task.first_doing_at {
244                        print!("doing: {} ", ts.format("%Y-%m-%d %H:%M:%S"));
245                    }
246                    if let Some(ts) = task.first_done_at {
247                        print!("done: {}", ts.format("%Y-%m-%d %H:%M:%S"));
248                    }
249                    if task.first_todo_at.is_some()
250                        || task.first_doing_at.is_some()
251                        || task.first_done_at.is_some()
252                    {
253                        println!();
254                    }
255                }
256                return Ok(());
257            },
258            Err(_) => {
259                // Task not found, fall through to FTS5 search
260                // (user might be searching for "#123" as text)
261            },
262        }
263    }
264
265    // Check if query is a status keyword combination
266    if let Some(statuses) = parse_status_keywords(query) {
267        // Collect tasks for each status
268        // When date filters are used, fetch more tasks initially
269        // (we'll apply limit after filtering)
270        const DATE_FILTER_FETCH_LIMIT: i64 = 10_000;
271        let fetch_limit = if since_dt.is_some() || until_dt.is_some() {
272            Some(DATE_FILTER_FETCH_LIMIT)
273        } else {
274            limit
275        };
276
277        let mut all_tasks = Vec::new();
278        for status in &statuses {
279            let result = task_mgr
280                .find_tasks(Some(status.clone()), None, None, fetch_limit, offset)
281                .await?;
282            all_tasks.extend(result.tasks);
283        }
284
285        // Apply date filters based on status
286        if since_dt.is_some() || until_dt.is_some() {
287            all_tasks.retain(|task| {
288                // Determine which timestamp to use based on task status
289                let timestamp = match task.status.as_str() {
290                    "done" => task.first_done_at,
291                    "doing" => task.first_doing_at,
292                    _ => task.first_todo_at, // todo or unknown
293                };
294
295                // If no timestamp, exclude from date-filtered results
296                let Some(ts) = timestamp else {
297                    return false;
298                };
299
300                // Check since filter
301                if let Some(ref since) = since_dt {
302                    if ts < *since {
303                        return false;
304                    }
305                }
306
307                // Check until filter
308                if let Some(ref until) = until_dt {
309                    if ts > *until {
310                        return false;
311                    }
312                }
313
314                true
315            });
316        }
317
318        // Sort by priority, then by id
319        all_tasks.sort_by(|a, b| {
320            let pri_a = a.priority.unwrap_or(999);
321            let pri_b = b.priority.unwrap_or(999);
322            pri_a.cmp(&pri_b).then_with(|| a.id.cmp(&b.id))
323        });
324
325        // Apply limit if specified
326        let limit = limit.unwrap_or(100) as usize;
327        if all_tasks.len() > limit {
328            all_tasks.truncate(limit);
329        }
330
331        if format == "json" {
332            println!("{}", serde_json::to_string_pretty(&all_tasks)?);
333        } else {
334            // Text format: status filter results
335            let status_str = statuses.join(", ");
336            let date_filter_str = match (&since, &until) {
337                (Some(s), Some(u)) => format!(" (from {} to {})", s, u),
338                (Some(s), None) => format!(" (since {})", s),
339                (None, Some(u)) => format!(" (until {})", u),
340                (None, None) => String::new(),
341            };
342            println!(
343                "Tasks with status [{}]{}: {} found",
344                status_str,
345                date_filter_str,
346                all_tasks.len()
347            );
348            println!();
349            for task in &all_tasks {
350                let status_icon = match task.status.as_str() {
351                    "todo" => "○",
352                    "doing" => "●",
353                    "done" => "✓",
354                    _ => "?",
355                };
356                let parent_info = task
357                    .parent_id
358                    .map(|p| format!(" (parent: #{})", p))
359                    .unwrap_or_default();
360                let priority_info = task
361                    .priority
362                    .map(|p| format!(" [P{}]", p))
363                    .unwrap_or_default();
364                println!(
365                    "  {} #{} {}{}{}",
366                    status_icon, task.id, task.name, parent_info, priority_info
367                );
368                if let Some(spec) = &task.spec {
369                    if !spec.is_empty() {
370                        println!("      Spec: {}", truncate_str(spec, 60));
371                    }
372                }
373                println!("      Owner: {}", task.owner);
374                if let Some(ts) = task.first_todo_at {
375                    print!("      todo: {} ", ts.format("%m-%d %H:%M:%S"));
376                }
377                if let Some(ts) = task.first_doing_at {
378                    print!("doing: {} ", ts.format("%m-%d %H:%M:%S"));
379                }
380                if let Some(ts) = task.first_done_at {
381                    print!("done: {}", ts.format("%m-%d %H:%M:%S"));
382                }
383                if task.first_todo_at.is_some()
384                    || task.first_doing_at.is_some()
385                    || task.first_done_at.is_some()
386                {
387                    println!();
388                }
389            }
390        }
391        return Ok(());
392    }
393
394    // Regular FTS5 search
395    if since_dt.is_some() || until_dt.is_some() {
396        eprintln!("Warning: --since/--until are ignored for fulltext search (only apply to status keyword queries)");
397    }
398    let results = task_mgr
399        .search(
400            query.to_string(),
401            include_tasks,
402            include_events,
403            limit,
404            offset,
405        )
406        .await?;
407
408    if format == "json" {
409        println!("{}", serde_json::to_string_pretty(&results)?);
410    } else {
411        use crate::db::models::SearchResult;
412
413        // Text format: FTS5 search results
414        println!(
415            "Search: \"{}\" → {} tasks, {} events (limit: {}, offset: {})",
416            query, results.total_tasks, results.total_events, results.limit, results.offset
417        );
418        println!();
419
420        for result in &results.results {
421            match result {
422                SearchResult::Task {
423                    task,
424                    match_field,
425                    match_snippet,
426                } => {
427                    let status_icon = match task.status.as_str() {
428                        "todo" => "○",
429                        "doing" => "●",
430                        "done" => "✓",
431                        _ => "?",
432                    };
433                    let parent_info = task
434                        .parent_id
435                        .map(|p| format!(" (parent: #{})", p))
436                        .unwrap_or_default();
437                    let priority_info = task
438                        .priority
439                        .map(|p| format!(" [P{}]", p))
440                        .unwrap_or_default();
441                    println!(
442                        "  {} #{} {} [match: {}]{}{}",
443                        status_icon, task.id, task.name, match_field, parent_info, priority_info
444                    );
445                    if let Some(spec) = &task.spec {
446                        if !spec.is_empty() {
447                            println!("      Spec: {}", truncate_str(spec, 60));
448                        }
449                    }
450                    if !match_snippet.is_empty() {
451                        println!("      Snippet: {}", match_snippet);
452                    }
453                    println!("      Owner: {}", task.owner);
454                    if let Some(ts) = task.first_todo_at {
455                        print!("      todo: {} ", ts.format("%m-%d %H:%M:%S"));
456                    }
457                    if let Some(ts) = task.first_doing_at {
458                        print!("doing: {} ", ts.format("%m-%d %H:%M:%S"));
459                    }
460                    if let Some(ts) = task.first_done_at {
461                        print!("done: {}", ts.format("%m-%d %H:%M:%S"));
462                    }
463                    if task.first_todo_at.is_some()
464                        || task.first_doing_at.is_some()
465                        || task.first_done_at.is_some()
466                    {
467                        println!();
468                    }
469                },
470                SearchResult::Event {
471                    event,
472                    task_chain,
473                    match_snippet,
474                } => {
475                    let icon = match event.log_type.as_str() {
476                        "decision" => "💡",
477                        "blocker" => "🚫",
478                        "milestone" => "🎯",
479                        _ => "📝",
480                    };
481                    println!(
482                        "  {} #{} [{}] (task #{}) {}",
483                        icon,
484                        event.id,
485                        event.log_type,
486                        event.task_id,
487                        event.timestamp.format("%Y-%m-%d %H:%M:%S")
488                    );
489                    println!("      Message: {}", event.discussion_data);
490                    if !match_snippet.is_empty() {
491                        println!("      Snippet: {}", match_snippet);
492                    }
493                    if !task_chain.is_empty() {
494                        let chain_str: Vec<String> = task_chain
495                            .iter()
496                            .map(|t| format!("#{} {}", t.id, t.name))
497                            .collect();
498                        println!("      Task chain: {}", chain_str.join(" → "));
499                    }
500                },
501            }
502        }
503
504        if results.has_more {
505            println!();
506            println!(
507                "  ... more results available (use --offset {})",
508                results.offset + results.limit
509            );
510        }
511    }
512    Ok(())
513}
514
515pub async fn handle_doctor_command() -> Result<()> {
516    use crate::cli_handlers::dashboard::{check_dashboard_health, DASHBOARD_PORT};
517
518    // Get database path info
519    let db_path_info = ProjectContext::get_database_path_info();
520
521    // Print database location
522    println!("Database:");
523    if let Some(db_path) = &db_path_info.final_database_path {
524        println!("  {}", db_path);
525    } else {
526        println!("  Not found");
527    }
528    println!();
529
530    // Print ancestor directories with databases
531    let dirs_with_db: Vec<&String> = db_path_info
532        .directories_checked
533        .iter()
534        .filter(|d| d.has_intent_engine)
535        .map(|d| &d.path)
536        .collect();
537
538    if !dirs_with_db.is_empty() {
539        println!("Ancestor directories with databases:");
540        for dir in dirs_with_db {
541            println!("  {}", dir);
542        }
543    } else {
544        println!("Ancestor directories with databases: None");
545    }
546    println!();
547
548    // Check dashboard status
549    print!("Dashboard: ");
550    let dashboard_health = check_dashboard_health(DASHBOARD_PORT).await;
551    if dashboard_health {
552        println!("Running (http://127.0.0.1:{})", DASHBOARD_PORT);
553    } else {
554        println!("Not running (start with 'ie dashboard start')");
555    }
556
557    Ok(())
558}
559
560pub async fn handle_init_command(at: Option<String>, force: bool) -> Result<()> {
561    use serde_json::json;
562
563    // Determine target directory
564    let target_dir = if let Some(path) = &at {
565        let p = PathBuf::from(path);
566        if !p.exists() {
567            return Err(IntentError::InvalidInput(format!(
568                "Directory does not exist: {}",
569                path
570            )));
571        }
572        if !p.is_dir() {
573            return Err(IntentError::InvalidInput(format!(
574                "Path is not a directory: {}",
575                path
576            )));
577        }
578        p
579    } else {
580        // Use current working directory
581        std::env::current_dir().expect("Failed to get current directory")
582    };
583
584    let intent_dir = target_dir.join(".intent-engine");
585
586    // Check if already exists
587    if intent_dir.exists() && !force {
588        let error_msg = format!(
589            ".intent-engine already exists at {}\nUse --force to re-initialize",
590            intent_dir.display()
591        );
592        return Err(IntentError::InvalidInput(error_msg));
593    }
594
595    // Perform initialization
596    let ctx = ProjectContext::initialize_project_at(target_dir).await?;
597
598    // Success output
599    let result = json!({
600        "success": true,
601        "root": ctx.root.display().to_string(),
602        "database_path": ctx.db_path.display().to_string(),
603        "message": "Intent-Engine initialized successfully"
604    });
605
606    println!("{}", serde_json::to_string_pretty(&result)?);
607    Ok(())
608}
609
610pub async fn handle_session_restore(
611    include_events: usize,
612    workspace: Option<String>,
613) -> Result<()> {
614    use crate::session_restore::SessionRestoreManager;
615
616    // If workspace path is specified, change to that directory
617    if let Some(ws_path) = workspace {
618        std::env::set_current_dir(&ws_path)?;
619    }
620
621    // Try to load project context
622    let ctx = match ProjectContext::load().await {
623        Ok(ctx) => ctx,
624        Err(_) => {
625            // Workspace not found
626            let result = crate::session_restore::SessionRestoreResult {
627                status: crate::session_restore::SessionStatus::Error,
628                workspace_path: std::env::current_dir()
629                    .ok()
630                    .and_then(|p| p.to_str().map(String::from)),
631                current_task: None,
632                parent_task: None,
633                siblings: None,
634                children: None,
635                recent_events: None,
636                suggested_commands: Some(vec![
637                    "ie workspace init".to_string(),
638                    "ie help".to_string(),
639                ]),
640                stats: None,
641                recommended_task: None,
642                top_pending_tasks: None,
643                error_type: Some(crate::session_restore::ErrorType::WorkspaceNotFound),
644                message: Some("No Intent-Engine workspace found in current directory".to_string()),
645                recovery_suggestion: Some(
646                    "Run 'ie workspace init' to create a new workspace".to_string(),
647                ),
648            };
649            println!("{}", serde_json::to_string_pretty(&result)?);
650            return Ok(());
651        },
652    };
653
654    let restore_mgr = SessionRestoreManager::new(&ctx.pool);
655    let result = restore_mgr.restore(include_events).await?;
656
657    println!("{}", serde_json::to_string_pretty(&result)?);
658
659    Ok(())
660}
661
662pub fn handle_logs_command(
663    mode: Option<String>,
664    level: Option<String>,
665    since: Option<String>,
666    until: Option<String>,
667    limit: Option<usize>,
668    follow: bool,
669    export: String,
670) -> Result<()> {
671    use crate::logs::{
672        follow_logs, format_entry_json, format_entry_text, parse_duration, query_logs, LogQuery,
673    };
674
675    // Build query
676    let mut query = LogQuery {
677        mode,
678        level,
679        limit,
680        ..Default::default()
681    };
682
683    if let Some(since_str) = since {
684        query.since = parse_duration(&since_str);
685        if query.since.is_none() {
686            return Err(IntentError::InvalidInput(format!(
687                "Invalid duration format: {}. Use format like '1h', '24h', '7d'",
688                since_str
689            )));
690        }
691    }
692
693    if let Some(until_str) = until {
694        use chrono::DateTime;
695        match DateTime::parse_from_rfc3339(&until_str) {
696            Ok(dt) => query.until = Some(dt.with_timezone(&chrono::Utc)),
697            Err(e) => {
698                return Err(IntentError::InvalidInput(format!(
699                    "Invalid timestamp format: {}. Error: {}",
700                    until_str, e
701                )))
702            },
703        }
704    }
705
706    // Handle follow mode
707    if follow {
708        return follow_logs(&query).map_err(IntentError::IoError);
709    }
710
711    // Query logs
712    let entries = query_logs(&query).map_err(IntentError::IoError)?;
713
714    if entries.is_empty() {
715        eprintln!("No log entries found matching the criteria");
716        return Ok(());
717    }
718
719    // Display results
720    match export.as_str() {
721        "json" => {
722            println!("[");
723            for (i, entry) in entries.iter().enumerate() {
724                print!("  {}", format_entry_json(entry));
725                if i < entries.len() - 1 {
726                    println!(",");
727                } else {
728                    println!();
729                }
730            }
731            println!("]");
732        },
733        _ => {
734            for entry in entries {
735                println!("{}", format_entry_text(&entry));
736            }
737        },
738    }
739
740    Ok(())
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746
747    // ============================================================================
748    // truncate_str tests (UTF-8 safe truncation)
749    // ============================================================================
750
751    #[test]
752    fn test_truncate_str_ascii() {
753        // Short string - no truncation
754        assert_eq!(truncate_str("hello", 10), "hello");
755
756        // Exact length - no truncation
757        assert_eq!(truncate_str("hello", 5), "hello");
758
759        // Needs truncation
760        assert_eq!(truncate_str("hello world", 8), "hello...");
761    }
762
763    #[test]
764    fn test_truncate_str_chinese() {
765        // Short Chinese string - no truncation
766        assert_eq!(truncate_str("你好", 10), "你好");
767
768        // Chinese string needs truncation
769        // "根据覆盖缺口分析补充" = 10 chars, truncate to 8 means keep 5 + "..."
770        let chinese = "根据覆盖缺口分析补充";
771        let result = truncate_str(chinese, 8);
772        assert_eq!(result, "根据覆盖缺...");
773        assert!(!result.contains('\u{FFFD}')); // No replacement chars
774    }
775
776    #[test]
777    fn test_truncate_str_mixed() {
778        // Mixed ASCII and Chinese
779        let mixed = "Task: 实现用户认证功能";
780        let result = truncate_str(mixed, 12);
781        assert_eq!(result, "Task: 实现用...");
782    }
783
784    #[test]
785    fn test_truncate_str_edge_cases() {
786        // Empty string
787        assert_eq!(truncate_str("", 10), "");
788
789        // Max chars less than 3 (edge case for "...")
790        assert_eq!(truncate_str("hello", 3), "...");
791
792        // Single char with truncation
793        assert_eq!(truncate_str("hello", 4), "h...");
794    }
795
796    #[test]
797    fn test_truncate_str_emoji() {
798        // Emoji (multi-byte UTF-8)
799        let emoji = "🚀🎉🔥💡";
800        let result = truncate_str(emoji, 4);
801        assert_eq!(result, "🚀🎉🔥💡"); // No truncation needed
802
803        let result = truncate_str(emoji, 3);
804        assert_eq!(result, "..."); // All replaced by ...
805    }
806}