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::workspace::WorkspaceManager;
10use std::path::PathBuf;
11
12#[allow(dead_code)]
14pub enum CurrentAction {
15 Set { task_id: i64 },
16 Clear,
17}
18
19#[allow(dead_code)]
20pub enum EventCommands {
21 Add {
22 task_id: Option<i64>,
23 log_type: String,
24 data_stdin: bool,
25 },
26 List {
27 task_id: Option<i64>,
28 log_type: Option<String>,
29 since: Option<String>,
30 limit: Option<i64>,
31 },
32}
33
34pub async fn handle_current_command(
35 set: Option<i64>,
36 command: Option<CurrentAction>,
37) -> Result<()> {
38 let ctx = ProjectContext::load().await?;
39 let workspace_mgr = WorkspaceManager::new(&ctx.pool);
40
41 if let Some(task_id) = set {
43 eprintln!("⚠️ Warning: 'ie current --set' is a low-level atomic command.");
44 eprintln!(
45 " For normal use, prefer 'ie task start {}' which ensures data consistency.",
46 task_id
47 );
48 eprintln!();
49 let response = workspace_mgr.set_current_task(task_id, None).await?;
50 println!("✓ Switched to task #{}", task_id);
51 println!("{}", serde_json::to_string_pretty(&response)?);
52 return Ok(());
53 }
54
55 match command {
57 Some(CurrentAction::Set { task_id }) => {
58 eprintln!("⚠️ Warning: 'ie current set' is a low-level atomic command.");
59 eprintln!(
60 " For normal use, prefer 'ie task start {}' which ensures data consistency.",
61 task_id
62 );
63 eprintln!();
64 let response = workspace_mgr.set_current_task(task_id, None).await?;
65 println!("✓ Switched to task #{}", task_id);
66 println!("{}", serde_json::to_string_pretty(&response)?);
67 },
68 Some(CurrentAction::Clear) => {
69 eprintln!("⚠️ Warning: 'ie current clear' is a low-level atomic command.");
70 eprintln!(" For normal use, prefer 'ie task done' or 'ie task switch' which ensures data consistency.");
71 eprintln!();
72 workspace_mgr.clear_current_task(None).await?;
73 println!("✓ Current task cleared");
74 },
75 None => {
76 let response = workspace_mgr.get_current_task(None).await?;
78 println!("{}", serde_json::to_string_pretty(&response)?);
79 },
80 }
81
82 Ok(())
83}
84
85pub async fn handle_report_command(
86 since: Option<String>,
87 status: Option<String>,
88 filter_name: Option<String>,
89 filter_spec: Option<String>,
90 summary_only: bool,
91) -> Result<()> {
92 let ctx = ProjectContext::load().await?;
93 let report_mgr = ReportManager::new(&ctx.pool);
94
95 let report = report_mgr
96 .generate_report(since, status, filter_name, filter_spec, summary_only)
97 .await?;
98 println!("{}", serde_json::to_string_pretty(&report)?);
99
100 Ok(())
101}
102
103pub async fn handle_event_command(cmd: EventCommands) -> Result<()> {
104 match cmd {
105 EventCommands::Add {
106 task_id,
107 log_type,
108 data_stdin,
109 } => {
110 let ctx = ProjectContext::load_or_init().await?;
111 let project_path = ctx.root.to_string_lossy().to_string();
112 let event_mgr = EventManager::with_project_path(&ctx.pool, project_path);
113
114 let data = if data_stdin {
115 read_stdin()?
116 } else {
117 return Err(IntentError::InvalidInput(
118 "--data-stdin is required".to_string(),
119 ));
120 };
121
122 let target_task_id = if let Some(id) = task_id {
124 id
126 } else {
127 let session_id = crate::workspace::resolve_session_id(None);
129 let current_task_id: Option<i64> = sqlx::query_scalar::<_, Option<i64>>(
130 "SELECT current_task_id FROM sessions WHERE session_id = ?",
131 )
132 .bind(&session_id)
133 .fetch_optional(&ctx.pool)
134 .await?
135 .flatten();
136
137 current_task_id
138 .ok_or_else(|| IntentError::InvalidInput(
139 "No current task is set and --task-id was not provided. Use 'current --set <ID>' to set a task first.".to_string(),
140 ))?
141 };
142
143 let event = event_mgr
144 .add_event(target_task_id, &log_type, &data)
145 .await?;
146 println!("{}", serde_json::to_string_pretty(&event)?);
147 },
148
149 EventCommands::List {
150 task_id,
151 limit,
152 log_type,
153 since,
154 } => {
155 let ctx = ProjectContext::load().await?;
156 let event_mgr = EventManager::new(&ctx.pool);
157
158 let events = event_mgr
159 .list_events(task_id, limit, log_type, since)
160 .await?;
161 println!("{}", serde_json::to_string_pretty(&events)?);
162 },
163 }
164
165 Ok(())
166}
167
168fn parse_status_keywords(query: &str) -> Option<Vec<String>> {
171 let query_lower = query.to_lowercase();
172 let words: Vec<&str> = query_lower.split_whitespace().collect();
173
174 if words.is_empty() {
176 return None;
177 }
178
179 let valid_statuses = ["todo", "doing", "done"];
181 let mut statuses: Vec<String> = Vec::new();
182
183 for word in words {
184 if valid_statuses.contains(&word) {
185 if !statuses.iter().any(|s| s == word) {
186 statuses.push(word.to_string());
187 }
188 } else {
189 return None;
191 }
192 }
193
194 Some(statuses)
195}
196
197fn parse_date_filter(input: &str) -> std::result::Result<chrono::DateTime<chrono::Utc>, String> {
199 use crate::time_utils::parse_duration;
200 use chrono::{NaiveDate, TimeZone, Utc};
201
202 let input = input.trim();
203
204 if let Ok(dt) = parse_duration(input) {
206 return Ok(dt);
207 }
208
209 if let Ok(date) = NaiveDate::parse_from_str(input, "%Y-%m-%d") {
211 let dt = Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0).unwrap());
212 return Ok(dt);
213 }
214
215 Err(format!(
216 "Invalid date format '{}'. Use duration (7d, 1w) or date (2025-01-01)",
217 input
218 ))
219}
220
221#[allow(clippy::too_many_arguments)]
222pub async fn handle_search_command(
223 query: &str,
224 include_tasks: bool,
225 include_events: bool,
226 limit: Option<i64>,
227 offset: Option<i64>,
228 since: Option<String>,
229 until: Option<String>,
230 format: &str,
231) -> Result<()> {
232 use crate::search::SearchManager;
233 use crate::tasks::TaskManager;
234 use chrono::{DateTime, Utc};
235
236 let ctx = ProjectContext::load_or_init().await?;
237
238 let since_dt: Option<DateTime<Utc>> = if let Some(ref s) = since {
240 Some(parse_date_filter(s).map_err(IntentError::InvalidInput)?)
241 } else {
242 None
243 };
244
245 let until_dt: Option<DateTime<Utc>> = if let Some(ref u) = until {
246 Some(parse_date_filter(u).map_err(IntentError::InvalidInput)?)
247 } else {
248 None
249 };
250
251 if let Some(statuses) = parse_status_keywords(query) {
253 let task_mgr = TaskManager::new(&ctx.pool);
255
256 let fetch_limit = if since_dt.is_some() || until_dt.is_some() {
260 Some(10000) } else {
262 limit
263 };
264
265 let mut all_tasks = Vec::new();
266 for status in &statuses {
267 let result = task_mgr
268 .find_tasks(Some(status), None, None, fetch_limit, offset)
269 .await?;
270 all_tasks.extend(result.tasks);
271 }
272
273 if since_dt.is_some() || until_dt.is_some() {
275 all_tasks.retain(|task| {
276 let timestamp = match task.status.as_str() {
278 "done" => task.first_done_at,
279 "doing" => task.first_doing_at,
280 _ => task.first_todo_at, };
282
283 let Some(ts) = timestamp else {
285 return false;
286 };
287
288 if let Some(ref since) = since_dt {
290 if ts < *since {
291 return false;
292 }
293 }
294
295 if let Some(ref until) = until_dt {
297 if ts > *until {
298 return false;
299 }
300 }
301
302 true
303 });
304 }
305
306 all_tasks.sort_by(|a, b| {
308 let pri_a = a.priority.unwrap_or(999);
309 let pri_b = b.priority.unwrap_or(999);
310 pri_a.cmp(&pri_b).then_with(|| a.id.cmp(&b.id))
311 });
312
313 let limit = limit.unwrap_or(100) as usize;
315 if all_tasks.len() > limit {
316 all_tasks.truncate(limit);
317 }
318
319 if format == "json" {
320 println!("{}", serde_json::to_string_pretty(&all_tasks)?);
321 } else {
322 let status_str = statuses.join(", ");
324 let date_filter_str = match (&since, &until) {
325 (Some(s), Some(u)) => format!(" (from {} to {})", s, u),
326 (Some(s), None) => format!(" (since {})", s),
327 (None, Some(u)) => format!(" (until {})", u),
328 (None, None) => String::new(),
329 };
330 println!(
331 "Tasks with status [{}]{}: {} found",
332 status_str,
333 date_filter_str,
334 all_tasks.len()
335 );
336 println!();
337 for task in &all_tasks {
338 let status_icon = match task.status.as_str() {
339 "todo" => "○",
340 "doing" => "●",
341 "done" => "✓",
342 _ => "?",
343 };
344 let parent_info = task
345 .parent_id
346 .map(|p| format!(" (parent: #{})", p))
347 .unwrap_or_default();
348 let priority_info = task
349 .priority
350 .map(|p| format!(" [P{}]", p))
351 .unwrap_or_default();
352 println!(
353 " {} #{} {}{}{}",
354 status_icon, task.id, task.name, parent_info, priority_info
355 );
356 if let Some(spec) = &task.spec {
357 if !spec.is_empty() {
358 let truncated = if spec.len() > 60 {
359 format!("{}...", &spec[..57])
360 } else {
361 spec.clone()
362 };
363 println!(" Spec: {}", truncated);
364 }
365 }
366 println!(" Owner: {}", task.owner);
367 if let Some(ts) = task.first_todo_at {
368 print!(" todo: {} ", ts.format("%m-%d %H:%M:%S"));
369 }
370 if let Some(ts) = task.first_doing_at {
371 print!("doing: {} ", ts.format("%m-%d %H:%M:%S"));
372 }
373 if let Some(ts) = task.first_done_at {
374 print!("done: {}", ts.format("%m-%d %H:%M:%S"));
375 }
376 if task.first_todo_at.is_some()
377 || task.first_doing_at.is_some()
378 || task.first_done_at.is_some()
379 {
380 println!();
381 }
382 }
383 }
384 return Ok(());
385 }
386
387 let search_mgr = SearchManager::new(&ctx.pool);
389
390 let results = search_mgr
391 .search(query, include_tasks, include_events, limit, offset, false)
392 .await?;
393
394 if format == "json" {
395 println!("{}", serde_json::to_string_pretty(&results)?);
396 } else {
397 use crate::db::models::SearchResult;
398
399 println!(
401 "Search: \"{}\" → {} tasks, {} events (limit: {}, offset: {})",
402 query, results.total_tasks, results.total_events, results.limit, results.offset
403 );
404 println!();
405
406 for result in &results.results {
407 match result {
408 SearchResult::Task {
409 task,
410 match_field,
411 match_snippet,
412 } => {
413 let status_icon = match task.status.as_str() {
414 "todo" => "○",
415 "doing" => "●",
416 "done" => "✓",
417 _ => "?",
418 };
419 let parent_info = task
420 .parent_id
421 .map(|p| format!(" (parent: #{})", p))
422 .unwrap_or_default();
423 let priority_info = task
424 .priority
425 .map(|p| format!(" [P{}]", p))
426 .unwrap_or_default();
427 println!(
428 " {} #{} {} [match: {}]{}{}",
429 status_icon, task.id, task.name, match_field, parent_info, priority_info
430 );
431 if let Some(spec) = &task.spec {
432 if !spec.is_empty() {
433 let truncated = if spec.len() > 60 {
434 format!("{}...", &spec[..57])
435 } else {
436 spec.clone()
437 };
438 println!(" Spec: {}", truncated);
439 }
440 }
441 if !match_snippet.is_empty() {
442 println!(" Snippet: {}", match_snippet);
443 }
444 println!(" Owner: {}", task.owner);
445 if let Some(ts) = task.first_todo_at {
446 print!(" todo: {} ", ts.format("%m-%d %H:%M:%S"));
447 }
448 if let Some(ts) = task.first_doing_at {
449 print!("doing: {} ", ts.format("%m-%d %H:%M:%S"));
450 }
451 if let Some(ts) = task.first_done_at {
452 print!("done: {}", ts.format("%m-%d %H:%M:%S"));
453 }
454 if task.first_todo_at.is_some()
455 || task.first_doing_at.is_some()
456 || task.first_done_at.is_some()
457 {
458 println!();
459 }
460 },
461 SearchResult::Event {
462 event,
463 task_chain,
464 match_snippet,
465 } => {
466 let icon = match event.log_type.as_str() {
467 "decision" => "💡",
468 "blocker" => "🚫",
469 "milestone" => "🎯",
470 _ => "📝",
471 };
472 println!(
473 " {} #{} [{}] (task #{}) {}",
474 icon,
475 event.id,
476 event.log_type,
477 event.task_id,
478 event.timestamp.format("%Y-%m-%d %H:%M:%S")
479 );
480 println!(" Message: {}", event.discussion_data);
481 if !match_snippet.is_empty() {
482 println!(" Snippet: {}", match_snippet);
483 }
484 if !task_chain.is_empty() {
485 let chain_str: Vec<String> = task_chain
486 .iter()
487 .map(|t| format!("#{} {}", t.id, t.name))
488 .collect();
489 println!(" Task chain: {}", chain_str.join(" → "));
490 }
491 },
492 }
493 }
494
495 if results.has_more {
496 println!();
497 println!(
498 " ... more results available (use --offset {})",
499 results.offset + results.limit
500 );
501 }
502 }
503 Ok(())
504}
505
506pub async fn handle_doctor_command() -> Result<()> {
507 use crate::cli_handlers::dashboard::{check_dashboard_health, DASHBOARD_PORT};
508
509 let db_path_info = ProjectContext::get_database_path_info();
511
512 println!("Database:");
514 if let Some(db_path) = &db_path_info.final_database_path {
515 println!(" {}", db_path);
516 } else {
517 println!(" Not found");
518 }
519 println!();
520
521 let dirs_with_db: Vec<&String> = db_path_info
523 .directories_checked
524 .iter()
525 .filter(|d| d.has_intent_engine)
526 .map(|d| &d.path)
527 .collect();
528
529 if !dirs_with_db.is_empty() {
530 println!("Ancestor directories with databases:");
531 for dir in dirs_with_db {
532 println!(" {}", dir);
533 }
534 } else {
535 println!("Ancestor directories with databases: None");
536 }
537 println!();
538
539 print!("Dashboard: ");
541 let dashboard_health = check_dashboard_health(DASHBOARD_PORT).await;
542 if dashboard_health {
543 println!("Running (http://127.0.0.1:{})", DASHBOARD_PORT);
544 } else {
545 println!("Not running (start with 'ie dashboard start')");
546 }
547
548 Ok(())
549}
550
551pub async fn handle_init_command(at: Option<String>, force: bool) -> Result<()> {
552 use serde_json::json;
553
554 let target_dir = if let Some(path) = &at {
556 let p = PathBuf::from(path);
557 if !p.exists() {
558 return Err(IntentError::InvalidInput(format!(
559 "Directory does not exist: {}",
560 path
561 )));
562 }
563 if !p.is_dir() {
564 return Err(IntentError::InvalidInput(format!(
565 "Path is not a directory: {}",
566 path
567 )));
568 }
569 p
570 } else {
571 std::env::current_dir().expect("Failed to get current directory")
573 };
574
575 let intent_dir = target_dir.join(".intent-engine");
576
577 if intent_dir.exists() && !force {
579 let error_msg = format!(
580 ".intent-engine already exists at {}\nUse --force to re-initialize",
581 intent_dir.display()
582 );
583 return Err(IntentError::InvalidInput(error_msg));
584 }
585
586 let ctx = ProjectContext::initialize_project_at(target_dir).await?;
588
589 let result = json!({
591 "success": true,
592 "root": ctx.root.display().to_string(),
593 "database_path": ctx.db_path.display().to_string(),
594 "message": "Intent-Engine initialized successfully"
595 });
596
597 println!("{}", serde_json::to_string_pretty(&result)?);
598 Ok(())
599}
600
601pub async fn handle_session_restore(
602 include_events: usize,
603 workspace: Option<String>,
604) -> Result<()> {
605 use crate::session_restore::SessionRestoreManager;
606
607 if let Some(ws_path) = workspace {
609 std::env::set_current_dir(&ws_path)?;
610 }
611
612 let ctx = match ProjectContext::load().await {
614 Ok(ctx) => ctx,
615 Err(_) => {
616 let result = crate::session_restore::SessionRestoreResult {
618 status: crate::session_restore::SessionStatus::Error,
619 workspace_path: std::env::current_dir()
620 .ok()
621 .and_then(|p| p.to_str().map(String::from)),
622 current_task: None,
623 parent_task: None,
624 siblings: None,
625 children: None,
626 recent_events: None,
627 suggested_commands: Some(vec![
628 "ie workspace init".to_string(),
629 "ie help".to_string(),
630 ]),
631 stats: None,
632 recommended_task: None,
633 top_pending_tasks: None,
634 error_type: Some(crate::session_restore::ErrorType::WorkspaceNotFound),
635 message: Some("No Intent-Engine workspace found in current directory".to_string()),
636 recovery_suggestion: Some(
637 "Run 'ie workspace init' to create a new workspace".to_string(),
638 ),
639 };
640 println!("{}", serde_json::to_string_pretty(&result)?);
641 return Ok(());
642 },
643 };
644
645 let restore_mgr = SessionRestoreManager::new(&ctx.pool);
646 let result = restore_mgr.restore(include_events).await?;
647
648 println!("{}", serde_json::to_string_pretty(&result)?);
649
650 Ok(())
651}
652
653pub fn handle_logs_command(
654 mode: Option<String>,
655 level: Option<String>,
656 since: Option<String>,
657 until: Option<String>,
658 limit: Option<usize>,
659 follow: bool,
660 export: String,
661) -> Result<()> {
662 use crate::logs::{
663 follow_logs, format_entry_json, format_entry_text, parse_duration, query_logs, LogQuery,
664 };
665
666 let mut query = LogQuery {
668 mode,
669 level,
670 limit,
671 ..Default::default()
672 };
673
674 if let Some(since_str) = since {
675 query.since = parse_duration(&since_str);
676 if query.since.is_none() {
677 return Err(IntentError::InvalidInput(format!(
678 "Invalid duration format: {}. Use format like '1h', '24h', '7d'",
679 since_str
680 )));
681 }
682 }
683
684 if let Some(until_str) = until {
685 use chrono::DateTime;
686 match DateTime::parse_from_rfc3339(&until_str) {
687 Ok(dt) => query.until = Some(dt.with_timezone(&chrono::Utc)),
688 Err(e) => {
689 return Err(IntentError::InvalidInput(format!(
690 "Invalid timestamp format: {}. Error: {}",
691 until_str, e
692 )))
693 },
694 }
695 }
696
697 if follow {
699 return follow_logs(&query).map_err(IntentError::IoError);
700 }
701
702 let entries = query_logs(&query).map_err(IntentError::IoError)?;
704
705 if entries.is_empty() {
706 eprintln!("No log entries found matching the criteria");
707 return Ok(());
708 }
709
710 match export.as_str() {
712 "json" => {
713 println!("[");
714 for (i, entry) in entries.iter().enumerate() {
715 print!(" {}", format_entry_json(entry));
716 if i < entries.len() - 1 {
717 println!(",");
718 } else {
719 println!();
720 }
721 }
722 println!("]");
723 },
724 _ => {
725 for entry in entries {
726 println!("{}", format_entry_text(&entry));
727 }
728 },
729 }
730
731 Ok(())
732}