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::workspace::WorkspaceManager;
10use std::path::PathBuf;
11
12// Stub types for deprecated CLI commands (no longer in cli.rs)
13#[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    // Handle backward compatibility: --set flag takes precedence
42    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    // Handle subcommands
56    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            // Default: display current task in JSON format
77            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            // Determine the target task ID
123            let target_task_id = if let Some(id) = task_id {
124                // Use the provided task_id
125                id
126            } else {
127                // Fall back to current_task_id from sessions table for this session
128                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
168/// Check if query is a status keyword combination (todo, doing, done)
169/// Returns Some(statuses) if it's a status query, None otherwise
170fn 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    // Must have at least one word
175    if words.is_empty() {
176        return None;
177    }
178
179    // All words must be status keywords
180    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            // Found a non-status word, not a status query
190            return None;
191        }
192    }
193
194    Some(statuses)
195}
196
197/// Parse a date filter string (duration like "7d" or date like "2025-01-01")
198fn 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    // Try duration format first (e.g., "7d", "1w")
205    if let Ok(dt) = parse_duration(input) {
206        return Ok(dt);
207    }
208
209    // Try date format (YYYY-MM-DD)
210    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    // Parse date filters
238    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    // Check if query is a status keyword combination
251    if let Some(statuses) = parse_status_keywords(query) {
252        // Use TaskManager::find_tasks for status filtering
253        let task_mgr = TaskManager::new(&ctx.pool);
254
255        // Collect tasks for each status
256        // When date filters are used, fetch more tasks initially
257        // (we'll apply limit after filtering)
258        let fetch_limit = if since_dt.is_some() || until_dt.is_some() {
259            Some(10000) // Large limit to fetch all relevant tasks
260        } 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        // Apply date filters based on status
273        if since_dt.is_some() || until_dt.is_some() {
274            all_tasks.retain(|task| {
275                // Determine which timestamp to use based on task status
276                let timestamp = match task.status.as_str() {
277                    "done" => task.first_done_at,
278                    "doing" => task.first_doing_at,
279                    _ => task.first_todo_at, // todo or unknown
280                };
281
282                // If no timestamp, exclude from date-filtered results
283                let Some(ts) = timestamp else {
284                    return false;
285                };
286
287                // Check since filter
288                if let Some(ref since) = since_dt {
289                    if ts < *since {
290                        return false;
291                    }
292                }
293
294                // Check until filter
295                if let Some(ref until) = until_dt {
296                    if ts > *until {
297                        return false;
298                    }
299                }
300
301                true
302            });
303        }
304
305        // Sort by priority, then by id
306        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        // Apply limit if specified
313        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            // Text format: status filter results
322            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    // Regular FTS5 search
387    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        // Text format: FTS5 search results
399        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    // Get database path info
509    let db_path_info = ProjectContext::get_database_path_info();
510
511    // Print database location
512    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    // Print ancestor directories with databases
521    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    // Check dashboard status
539    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    // Determine target directory
554    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        // Use current working directory
571        std::env::current_dir().expect("Failed to get current directory")
572    };
573
574    let intent_dir = target_dir.join(".intent-engine");
575
576    // Check if already exists
577    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    // Perform initialization
586    let ctx = ProjectContext::initialize_project_at(target_dir).await?;
587
588    // Success output
589    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 workspace path is specified, change to that directory
607    if let Some(ws_path) = workspace {
608        std::env::set_current_dir(&ws_path)?;
609    }
610
611    // Try to load project context
612    let ctx = match ProjectContext::load().await {
613        Ok(ctx) => ctx,
614        Err(_) => {
615            // Workspace not found
616            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    // Build query
666    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    // Handle follow mode
697    if follow {
698        return follow_logs(&query).map_err(IntentError::IoError);
699    }
700
701    // Query logs
702    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    // Display results
710    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}