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
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    // Parse date filters
239    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    // Check if query is a status keyword combination
252    if let Some(statuses) = parse_status_keywords(query) {
253        // Use TaskManager::find_tasks for status filtering
254        let task_mgr = TaskManager::new(&ctx.pool);
255
256        // Collect tasks for each status
257        // When date filters are used, fetch more tasks initially
258        // (we'll apply limit after filtering)
259        let fetch_limit = if since_dt.is_some() || until_dt.is_some() {
260            Some(10000) // Large limit to fetch all relevant tasks
261        } 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        // Apply date filters based on status
274        if since_dt.is_some() || until_dt.is_some() {
275            all_tasks.retain(|task| {
276                // Determine which timestamp to use based on task status
277                let timestamp = match task.status.as_str() {
278                    "done" => task.first_done_at,
279                    "doing" => task.first_doing_at,
280                    _ => task.first_todo_at, // todo or unknown
281                };
282
283                // If no timestamp, exclude from date-filtered results
284                let Some(ts) = timestamp else {
285                    return false;
286                };
287
288                // Check since filter
289                if let Some(ref since) = since_dt {
290                    if ts < *since {
291                        return false;
292                    }
293                }
294
295                // Check until filter
296                if let Some(ref until) = until_dt {
297                    if ts > *until {
298                        return false;
299                    }
300                }
301
302                true
303            });
304        }
305
306        // Sort by priority, then by id
307        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        // Apply limit if specified
314        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            // Text format: status filter results
323            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    // Regular FTS5 search
388    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        // Text format: FTS5 search results
400        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    // Get database path info
510    let db_path_info = ProjectContext::get_database_path_info();
511
512    // Print database location
513    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    // Print ancestor directories with databases
522    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    // Check dashboard status
540    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    // Determine target directory
555    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        // Use current working directory
572        std::env::current_dir().expect("Failed to get current directory")
573    };
574
575    let intent_dir = target_dir.join(".intent-engine");
576
577    // Check if already exists
578    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    // Perform initialization
587    let ctx = ProjectContext::initialize_project_at(target_dir).await?;
588
589    // Success output
590    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 workspace path is specified, change to that directory
608    if let Some(ws_path) = workspace {
609        std::env::set_current_dir(&ws_path)?;
610    }
611
612    // Try to load project context
613    let ctx = match ProjectContext::load().await {
614        Ok(ctx) => ctx,
615        Err(_) => {
616            // Workspace not found
617            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    // Build query
667    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    // Handle follow mode
698    if follow {
699        return follow_logs(&query).map_err(IntentError::IoError);
700    }
701
702    // Query logs
703    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    // Display results
711    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}