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
221pub async fn handle_search_command(
222 query: &str,
223 include_tasks: bool,
224 include_events: bool,
225 limit: Option<i64>,
226 offset: Option<i64>,
227 since: Option<String>,
228 until: Option<String>,
229 format: &str,
230) -> Result<()> {
231 use crate::search::SearchManager;
232 use crate::tasks::TaskManager;
233 use chrono::{DateTime, Utc};
234
235 let ctx = ProjectContext::load_or_init().await?;
236
237 let since_dt: Option<DateTime<Utc>> = if let Some(ref s) = since {
239 Some(parse_date_filter(s).map_err(|e| IntentError::InvalidInput(e))?)
240 } else {
241 None
242 };
243
244 let until_dt: Option<DateTime<Utc>> = if let Some(ref u) = until {
245 Some(parse_date_filter(u).map_err(|e| IntentError::InvalidInput(e))?)
246 } else {
247 None
248 };
249
250 if let Some(statuses) = parse_status_keywords(query) {
252 let task_mgr = TaskManager::new(&ctx.pool);
254
255 let fetch_limit = if since_dt.is_some() || until_dt.is_some() {
259 Some(10000) } else {
261 limit
262 };
263
264 let mut all_tasks = Vec::new();
265 for status in &statuses {
266 let result = task_mgr
267 .find_tasks(Some(status), None, None, fetch_limit, offset)
268 .await?;
269 all_tasks.extend(result.tasks);
270 }
271
272 if since_dt.is_some() || until_dt.is_some() {
274 all_tasks.retain(|task| {
275 let timestamp = match task.status.as_str() {
277 "done" => task.first_done_at,
278 "doing" => task.first_doing_at,
279 _ => task.first_todo_at, };
281
282 let Some(ts) = timestamp else {
284 return false;
285 };
286
287 if let Some(ref since) = since_dt {
289 if ts < *since {
290 return false;
291 }
292 }
293
294 if let Some(ref until) = until_dt {
296 if ts > *until {
297 return false;
298 }
299 }
300
301 true
302 });
303 }
304
305 all_tasks.sort_by(|a, b| {
307 let pri_a = a.priority.unwrap_or(999);
308 let pri_b = b.priority.unwrap_or(999);
309 pri_a.cmp(&pri_b).then_with(|| a.id.cmp(&b.id))
310 });
311
312 let limit = limit.unwrap_or(100) as usize;
314 if all_tasks.len() > limit {
315 all_tasks.truncate(limit);
316 }
317
318 if format == "json" {
319 println!("{}", serde_json::to_string_pretty(&all_tasks)?);
320 } else {
321 let status_str = statuses.join(", ");
323 let date_filter_str = match (&since, &until) {
324 (Some(s), Some(u)) => format!(" (from {} to {})", s, u),
325 (Some(s), None) => format!(" (since {})", s),
326 (None, Some(u)) => format!(" (until {})", u),
327 (None, None) => String::new(),
328 };
329 println!(
330 "Tasks with status [{}]{}: {} found",
331 status_str,
332 date_filter_str,
333 all_tasks.len()
334 );
335 println!();
336 for task in &all_tasks {
337 let status_icon = match task.status.as_str() {
338 "todo" => "○",
339 "doing" => "●",
340 "done" => "✓",
341 _ => "?",
342 };
343 let parent_info = task
344 .parent_id
345 .map(|p| format!(" (parent: #{})", p))
346 .unwrap_or_default();
347 let priority_info = task
348 .priority
349 .map(|p| format!(" [P{}]", p))
350 .unwrap_or_default();
351 println!(
352 " {} #{} {}{}{}",
353 status_icon, task.id, task.name, parent_info, priority_info
354 );
355 if let Some(spec) = &task.spec {
356 if !spec.is_empty() {
357 let truncated = if spec.len() > 60 {
358 format!("{}...", &spec[..57])
359 } else {
360 spec.clone()
361 };
362 println!(" Spec: {}", truncated);
363 }
364 }
365 println!(" Owner: {}", task.owner);
366 if let Some(ts) = task.first_todo_at {
367 print!(" todo: {} ", ts.format("%m-%d %H:%M:%S"));
368 }
369 if let Some(ts) = task.first_doing_at {
370 print!("doing: {} ", ts.format("%m-%d %H:%M:%S"));
371 }
372 if let Some(ts) = task.first_done_at {
373 print!("done: {}", ts.format("%m-%d %H:%M:%S"));
374 }
375 if task.first_todo_at.is_some()
376 || task.first_doing_at.is_some()
377 || task.first_done_at.is_some()
378 {
379 println!();
380 }
381 }
382 }
383 return Ok(());
384 }
385
386 let search_mgr = SearchManager::new(&ctx.pool);
388
389 let results = search_mgr
390 .search(query, include_tasks, include_events, limit, offset, false)
391 .await?;
392
393 if format == "json" {
394 println!("{}", serde_json::to_string_pretty(&results)?);
395 } else {
396 use crate::db::models::SearchResult;
397
398 println!(
400 "Search: \"{}\" → {} tasks, {} events (limit: {}, offset: {})",
401 query, results.total_tasks, results.total_events, results.limit, results.offset
402 );
403 println!();
404
405 for result in &results.results {
406 match result {
407 SearchResult::Task {
408 task,
409 match_field,
410 match_snippet,
411 } => {
412 let status_icon = match task.status.as_str() {
413 "todo" => "○",
414 "doing" => "●",
415 "done" => "✓",
416 _ => "?",
417 };
418 let parent_info = task
419 .parent_id
420 .map(|p| format!(" (parent: #{})", p))
421 .unwrap_or_default();
422 let priority_info = task
423 .priority
424 .map(|p| format!(" [P{}]", p))
425 .unwrap_or_default();
426 println!(
427 " {} #{} {} [match: {}]{}{}",
428 status_icon, task.id, task.name, match_field, parent_info, priority_info
429 );
430 if let Some(spec) = &task.spec {
431 if !spec.is_empty() {
432 let truncated = if spec.len() > 60 {
433 format!("{}...", &spec[..57])
434 } else {
435 spec.clone()
436 };
437 println!(" Spec: {}", truncated);
438 }
439 }
440 if !match_snippet.is_empty() {
441 println!(" Snippet: {}", match_snippet);
442 }
443 println!(" Owner: {}", task.owner);
444 if let Some(ts) = task.first_todo_at {
445 print!(" todo: {} ", ts.format("%m-%d %H:%M:%S"));
446 }
447 if let Some(ts) = task.first_doing_at {
448 print!("doing: {} ", ts.format("%m-%d %H:%M:%S"));
449 }
450 if let Some(ts) = task.first_done_at {
451 print!("done: {}", ts.format("%m-%d %H:%M:%S"));
452 }
453 if task.first_todo_at.is_some()
454 || task.first_doing_at.is_some()
455 || task.first_done_at.is_some()
456 {
457 println!();
458 }
459 },
460 SearchResult::Event {
461 event,
462 task_chain,
463 match_snippet,
464 } => {
465 let icon = match event.log_type.as_str() {
466 "decision" => "💡",
467 "blocker" => "🚫",
468 "milestone" => "🎯",
469 _ => "📝",
470 };
471 println!(
472 " {} #{} [{}] (task #{}) {}",
473 icon,
474 event.id,
475 event.log_type,
476 event.task_id,
477 event.timestamp.format("%Y-%m-%d %H:%M:%S")
478 );
479 println!(" Message: {}", event.discussion_data);
480 if !match_snippet.is_empty() {
481 println!(" Snippet: {}", match_snippet);
482 }
483 if !task_chain.is_empty() {
484 let chain_str: Vec<String> = task_chain
485 .iter()
486 .map(|t| format!("#{} {}", t.id, t.name))
487 .collect();
488 println!(" Task chain: {}", chain_str.join(" → "));
489 }
490 },
491 }
492 }
493
494 if results.has_more {
495 println!();
496 println!(
497 " ... more results available (use --offset {})",
498 results.offset + results.limit
499 );
500 }
501 }
502 Ok(())
503}
504
505pub async fn handle_doctor_command() -> Result<()> {
506 use crate::cli_handlers::dashboard::{check_dashboard_health, DASHBOARD_PORT};
507
508 let db_path_info = ProjectContext::get_database_path_info();
510
511 println!("Database:");
513 if let Some(db_path) = &db_path_info.final_database_path {
514 println!(" {}", db_path);
515 } else {
516 println!(" Not found");
517 }
518 println!();
519
520 let dirs_with_db: Vec<&String> = db_path_info
522 .directories_checked
523 .iter()
524 .filter(|d| d.has_intent_engine)
525 .map(|d| &d.path)
526 .collect();
527
528 if !dirs_with_db.is_empty() {
529 println!("Ancestor directories with databases:");
530 for dir in dirs_with_db {
531 println!(" {}", dir);
532 }
533 } else {
534 println!("Ancestor directories with databases: None");
535 }
536 println!();
537
538 print!("Dashboard: ");
540 let dashboard_health = check_dashboard_health(DASHBOARD_PORT).await;
541 if dashboard_health {
542 println!("Running (http://127.0.0.1:{})", DASHBOARD_PORT);
543 } else {
544 println!("Not running (start with 'ie dashboard start')");
545 }
546
547 Ok(())
548}
549
550pub async fn handle_init_command(at: Option<String>, force: bool) -> Result<()> {
551 use serde_json::json;
552
553 let target_dir = if let Some(path) = &at {
555 let p = PathBuf::from(path);
556 if !p.exists() {
557 return Err(IntentError::InvalidInput(format!(
558 "Directory does not exist: {}",
559 path
560 )));
561 }
562 if !p.is_dir() {
563 return Err(IntentError::InvalidInput(format!(
564 "Path is not a directory: {}",
565 path
566 )));
567 }
568 p
569 } else {
570 std::env::current_dir().expect("Failed to get current directory")
572 };
573
574 let intent_dir = target_dir.join(".intent-engine");
575
576 if intent_dir.exists() && !force {
578 let error_msg = format!(
579 ".intent-engine already exists at {}\nUse --force to re-initialize",
580 intent_dir.display()
581 );
582 return Err(IntentError::InvalidInput(error_msg));
583 }
584
585 let ctx = ProjectContext::initialize_project_at(target_dir).await?;
587
588 let result = json!({
590 "success": true,
591 "root": ctx.root.display().to_string(),
592 "database_path": ctx.db_path.display().to_string(),
593 "message": "Intent-Engine initialized successfully"
594 });
595
596 println!("{}", serde_json::to_string_pretty(&result)?);
597 Ok(())
598}
599
600pub async fn handle_session_restore(
601 include_events: usize,
602 workspace: Option<String>,
603) -> Result<()> {
604 use crate::session_restore::SessionRestoreManager;
605
606 if let Some(ws_path) = workspace {
608 std::env::set_current_dir(&ws_path)?;
609 }
610
611 let ctx = match ProjectContext::load().await {
613 Ok(ctx) => ctx,
614 Err(_) => {
615 let result = crate::session_restore::SessionRestoreResult {
617 status: crate::session_restore::SessionStatus::Error,
618 workspace_path: std::env::current_dir()
619 .ok()
620 .and_then(|p| p.to_str().map(String::from)),
621 current_task: None,
622 parent_task: None,
623 siblings: None,
624 children: None,
625 recent_events: None,
626 suggested_commands: Some(vec![
627 "ie workspace init".to_string(),
628 "ie help".to_string(),
629 ]),
630 stats: None,
631 recommended_task: None,
632 top_pending_tasks: None,
633 error_type: Some(crate::session_restore::ErrorType::WorkspaceNotFound),
634 message: Some("No Intent-Engine workspace found in current directory".to_string()),
635 recovery_suggestion: Some(
636 "Run 'ie workspace init' to create a new workspace".to_string(),
637 ),
638 };
639 println!("{}", serde_json::to_string_pretty(&result)?);
640 return Ok(());
641 },
642 };
643
644 let restore_mgr = SessionRestoreManager::new(&ctx.pool);
645 let result = restore_mgr.restore(include_events).await?;
646
647 println!("{}", serde_json::to_string_pretty(&result)?);
648
649 Ok(())
650}
651
652pub fn handle_logs_command(
653 mode: Option<String>,
654 level: Option<String>,
655 since: Option<String>,
656 until: Option<String>,
657 limit: Option<usize>,
658 follow: bool,
659 export: String,
660) -> Result<()> {
661 use crate::logs::{
662 follow_logs, format_entry_json, format_entry_text, parse_duration, query_logs, LogQuery,
663 };
664
665 let mut query = LogQuery {
667 mode,
668 level,
669 limit,
670 ..Default::default()
671 };
672
673 if let Some(since_str) = since {
674 query.since = parse_duration(&since_str);
675 if query.since.is_none() {
676 return Err(IntentError::InvalidInput(format!(
677 "Invalid duration format: {}. Use format like '1h', '24h', '7d'",
678 since_str
679 )));
680 }
681 }
682
683 if let Some(until_str) = until {
684 use chrono::DateTime;
685 match DateTime::parse_from_rfc3339(&until_str) {
686 Ok(dt) => query.until = Some(dt.with_timezone(&chrono::Utc)),
687 Err(e) => {
688 return Err(IntentError::InvalidInput(format!(
689 "Invalid timestamp format: {}. Error: {}",
690 until_str, e
691 )))
692 },
693 }
694 }
695
696 if follow {
698 return follow_logs(&query).map_err(IntentError::IoError);
699 }
700
701 let entries = query_logs(&query).map_err(IntentError::IoError)?;
703
704 if entries.is_empty() {
705 eprintln!("No log entries found matching the criteria");
706 return Ok(());
707 }
708
709 match export.as_str() {
711 "json" => {
712 println!("[");
713 for (i, entry) in entries.iter().enumerate() {
714 print!(" {}", format_entry_json(entry));
715 if i < entries.len() - 1 {
716 println!(",");
717 } else {
718 println!();
719 }
720 }
721 println!("]");
722 },
723 _ => {
724 for entry in entries {
725 println!("{}", format_entry_text(&entry));
726 }
727 },
728 }
729
730 Ok(())
731}