1use 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#[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 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 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 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 let target_task_id = if let Some(id) = task_id {
125 id
127 } else {
128 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
169fn parse_task_id_query(query: &str) -> Option<i64> {
172 let query = query.trim();
173
174 if !query.starts_with('#') || query.len() < 2 {
176 return None;
177 }
178
179 let id_part = &query[1..];
181 id_part.parse::<i64>().ok()
182}
183
184fn 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
196fn 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 if words.is_empty() {
204 return None;
205 }
206
207 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 return None;
219 }
220 }
221
222 Some(statuses)
223}
224
225fn 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 if let Ok(dt) = parse_duration(input) {
234 return Ok(dt);
235 }
236
237 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 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 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 },
333 }
334 }
335
336 if let Some(statuses) = parse_status_keywords(query) {
338 let task_mgr = TaskManager::new(&ctx.pool);
340
341 let fetch_limit = if since_dt.is_some() || until_dt.is_some() {
345 Some(10000) } 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 if since_dt.is_some() || until_dt.is_some() {
360 all_tasks.retain(|task| {
361 let timestamp = match task.status.as_str() {
363 "done" => task.first_done_at,
364 "doing" => task.first_doing_at,
365 _ => task.first_todo_at, };
367
368 let Some(ts) = timestamp else {
370 return false;
371 };
372
373 if let Some(ref since) = since_dt {
375 if ts < *since {
376 return false;
377 }
378 }
379
380 if let Some(ref until) = until_dt {
382 if ts > *until {
383 return false;
384 }
385 }
386
387 true
388 });
389 }
390
391 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 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 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 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 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 let db_path_info = ProjectContext::get_database_path_info();
586
587 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 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 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 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 std::env::current_dir().expect("Failed to get current directory")
648 };
649
650 let intent_dir = target_dir.join(".intent-engine");
651
652 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 let ctx = ProjectContext::initialize_project_at(target_dir).await?;
663
664 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 let Some(ws_path) = workspace {
684 std::env::set_current_dir(&ws_path)?;
685 }
686
687 let ctx = match ProjectContext::load().await {
689 Ok(ctx) => ctx,
690 Err(_) => {
691 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 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 if follow {
774 return follow_logs(&query).map_err(IntentError::IoError);
775 }
776
777 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 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 #[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 assert_eq!(parse_task_id_query("123"), None);
834 assert_eq!(parse_task_id_query("task"), None);
835
836 assert_eq!(parse_task_id_query("#"), None);
838
839 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 assert_eq!(parse_task_id_query("#123 task"), None);
846 assert_eq!(parse_task_id_query("task #123"), None);
847
848 assert_eq!(parse_task_id_query("#-1"), Some(-1));
851
852 assert_eq!(parse_task_id_query(""), None);
854 }
855
856 #[test]
861 fn test_truncate_str_ascii() {
862 assert_eq!(truncate_str("hello", 10), "hello");
864
865 assert_eq!(truncate_str("hello", 5), "hello");
867
868 assert_eq!(truncate_str("hello world", 8), "hello...");
870 }
871
872 #[test]
873 fn test_truncate_str_chinese() {
874 assert_eq!(truncate_str("你好", 10), "你好");
876
877 let chinese = "根据覆盖缺口分析补充";
880 let result = truncate_str(chinese, 8);
881 assert_eq!(result, "根据覆盖缺...");
882 assert!(!result.contains('\u{FFFD}')); }
884
885 #[test]
886 fn test_truncate_str_mixed() {
887 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 assert_eq!(truncate_str("", 10), "");
897
898 assert_eq!(truncate_str("hello", 3), "...");
900
901 assert_eq!(truncate_str("hello", 4), "h...");
903 }
904
905 #[test]
906 fn test_truncate_str_emoji() {
907 let emoji = "🚀🎉🔥💡";
909 let result = truncate_str(emoji, 4);
910 assert_eq!(result, "🚀🎉🔥💡"); let result = truncate_str(emoji, 3);
913 assert_eq!(result, "..."); }
915
916 #[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 assert_eq!(parse_status_keywords("todo task"), None);
961 assert_eq!(parse_status_keywords("search term"), None);
962
963 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 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}