1use 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#[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 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 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 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 let target_task_id = if let Some(id) = task_id {
127 id
129 } else {
130 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
169fn 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 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 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 },
262 }
263 }
264
265 if let Some(statuses) = parse_status_keywords(query) {
267 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 if since_dt.is_some() || until_dt.is_some() {
287 all_tasks.retain(|task| {
288 let timestamp = match task.status.as_str() {
290 "done" => task.first_done_at,
291 "doing" => task.first_doing_at,
292 _ => task.first_todo_at, };
294
295 let Some(ts) = timestamp else {
297 return false;
298 };
299
300 if let Some(ref since) = since_dt {
302 if ts < *since {
303 return false;
304 }
305 }
306
307 if let Some(ref until) = until_dt {
309 if ts > *until {
310 return false;
311 }
312 }
313
314 true
315 });
316 }
317
318 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 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 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 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 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 let db_path_info = ProjectContext::get_database_path_info();
520
521 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 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 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 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 std::env::current_dir().expect("Failed to get current directory")
582 };
583
584 let intent_dir = target_dir.join(".intent-engine");
585
586 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 let ctx = ProjectContext::initialize_project_at(target_dir).await?;
597
598 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 let Some(ws_path) = workspace {
618 std::env::set_current_dir(&ws_path)?;
619 }
620
621 let ctx = match ProjectContext::load().await {
623 Ok(ctx) => ctx,
624 Err(_) => {
625 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 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 if follow {
708 return follow_logs(&query).map_err(IntentError::IoError);
709 }
710
711 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 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 #[test]
752 fn test_truncate_str_ascii() {
753 assert_eq!(truncate_str("hello", 10), "hello");
755
756 assert_eq!(truncate_str("hello", 5), "hello");
758
759 assert_eq!(truncate_str("hello world", 8), "hello...");
761 }
762
763 #[test]
764 fn test_truncate_str_chinese() {
765 assert_eq!(truncate_str("你好", 10), "你好");
767
768 let chinese = "根据覆盖缺口分析补充";
771 let result = truncate_str(chinese, 8);
772 assert_eq!(result, "根据覆盖缺...");
773 assert!(!result.contains('\u{FFFD}')); }
775
776 #[test]
777 fn test_truncate_str_mixed() {
778 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 assert_eq!(truncate_str("", 10), "");
788
789 assert_eq!(truncate_str("hello", 3), "...");
791
792 assert_eq!(truncate_str("hello", 4), "h...");
794 }
795
796 #[test]
797 fn test_truncate_str_emoji() {
798 let emoji = "🚀🎉🔥💡";
800 let result = truncate_str(emoji, 4);
801 assert_eq!(result, "🚀🎉🔥💡"); let result = truncate_str(emoji, 3);
804 assert_eq!(result, "..."); }
806}