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