Skip to main content

intent_engine/cli_handlers/
utils.rs

1//! Utility functions for CLI handlers
2//!
3//! Helper functions for reading stdin, formatting status badges, and printing task contexts.
4
5use crate::db::models::{EventsSummary, Task, TaskContext};
6use crate::error::{IntentError, Result};
7use std::io::{self, Read};
8
9/// Read from stdin with proper encoding handling (especially for Windows PowerShell)
10pub fn read_stdin() -> Result<String> {
11    #[cfg(windows)]
12    {
13        use encoding_rs::GBK;
14
15        let mut buffer = Vec::new();
16        io::stdin().read_to_end(&mut buffer)?;
17
18        // First try UTF-8
19        if let Ok(s) = String::from_utf8(buffer.clone()) {
20            return Ok(s.trim().to_string());
21        }
22
23        // Fall back to GBK decoding (common in Chinese Windows PowerShell)
24        let (decoded, _, had_errors) = GBK.decode(&buffer);
25        if !had_errors {
26            tracing::debug!(
27                "Successfully decoded stdin from GBK encoding (Chinese Windows detected)"
28            );
29            Ok(decoded.trim().to_string())
30        } else {
31            // If GBK also fails, return the UTF-8 lossy version
32            tracing::warn!(
33                "Failed to decode stdin from both UTF-8 and GBK, using lossy UTF-8 conversion"
34            );
35            Ok(String::from_utf8_lossy(&buffer).trim().to_string())
36        }
37    }
38
39    #[cfg(not(windows))]
40    {
41        let mut buffer = String::new();
42        io::stdin().read_to_string(&mut buffer)?;
43        Ok(buffer.trim().to_string())
44    }
45}
46
47/// Get a status badge icon for task status (arrow style, used in `ie status`)
48pub fn get_status_badge(status: &str) -> &'static str {
49    match status {
50        "done" => "✓",
51        "doing" => "→",
52        "todo" => "○",
53        _ => "?",
54    }
55}
56
57/// Get a status icon for task status (bullet style, used in tree/list views)
58pub fn status_icon(status: &str) -> &'static str {
59    match status {
60        "todo" => "○",
61        "doing" => "●",
62        "done" => "✓",
63        _ => "?",
64    }
65}
66
67/// Print tasks in a hierarchical tree format
68pub fn print_task_tree(tasks: &[crate::db::models::Task]) {
69    use std::collections::HashMap;
70
71    // Build parent -> children map
72    let mut children_map: HashMap<Option<i64>, Vec<&crate::db::models::Task>> = HashMap::new();
73    for task in tasks {
74        children_map.entry(task.parent_id).or_default().push(task);
75    }
76
77    fn print_subtree(
78        children_map: &HashMap<Option<i64>, Vec<&crate::db::models::Task>>,
79        parent_id: Option<i64>,
80        indent: &str,
81    ) {
82        if let Some(children) = children_map.get(&parent_id) {
83            for (i, task) in children.iter().enumerate() {
84                let is_last_child = i == children.len() - 1;
85                let connector = if indent.is_empty() {
86                    ""
87                } else if is_last_child {
88                    "└─ "
89                } else {
90                    "├─ "
91                };
92                let icon = status_icon(&task.status);
93                let priority_info = task
94                    .priority
95                    .map(|p| format!(" [P{}]", p))
96                    .unwrap_or_default();
97
98                println!(
99                    "  {}{}{} #{} {}{}",
100                    indent, connector, icon, task.id, task.name, priority_info
101                );
102
103                let new_indent = if indent.is_empty() {
104                    "".to_string()
105                } else if is_last_child {
106                    format!("{}   ", indent)
107                } else {
108                    format!("{}│  ", indent)
109                };
110                print_subtree(children_map, Some(task.id), &new_indent);
111            }
112        }
113    }
114
115    // Start with root-level tasks (parent is None or parent not in our set)
116    let task_ids: std::collections::HashSet<i64> = tasks.iter().map(|t| t.id).collect();
117    let roots: Vec<&crate::db::models::Task> = tasks
118        .iter()
119        .filter(|t| t.parent_id.is_none() || !task_ids.contains(&t.parent_id.unwrap_or(-1)))
120        .collect();
121
122    for task in &roots {
123        let icon = status_icon(&task.status);
124        let priority_info = task
125            .priority
126            .map(|p| format!(" [P{}]", p))
127            .unwrap_or_default();
128        println!("  {} #{} {}{}", icon, task.id, task.name, priority_info);
129        print_subtree(&children_map, Some(task.id), "  ");
130    }
131}
132
133/// Print a concise task summary
134pub fn print_task_summary(task: &Task) {
135    let icon = status_icon(&task.status);
136    println!("  {} #{} {}", icon, task.id, task.name);
137    println!("  Status: {}", task.status);
138    if let Some(pid) = task.parent_id {
139        println!("  Parent: #{}", pid);
140    }
141    if let Some(p) = task.priority {
142        println!("  Priority: {}", p);
143    }
144    if let Some(spec) = &task.spec {
145        if !spec.is_empty() {
146            println!("  Spec: {}", spec);
147        }
148    }
149    println!("  Owner: {}", task.owner);
150    if let Some(af) = &task.active_form {
151        println!("  Active form: {}", af);
152    }
153    if let Some(meta) = &task.metadata {
154        println!("  Metadata: {}", meta);
155    }
156}
157
158/// Print task context in a human-friendly tree format
159pub fn print_task_context(ctx: &TaskContext) {
160    let icon = status_icon(&ctx.task.status);
161    println!("\n{} Task #{}: {}", icon, ctx.task.id, ctx.task.name);
162    println!("Status: {}", ctx.task.status);
163
164    if let Some(spec) = &ctx.task.spec {
165        println!("\nSpec:");
166        for line in spec.lines() {
167            println!("  {}", line);
168        }
169    }
170
171    // Print parent chain
172    if !ctx.ancestors.is_empty() {
173        println!("\nParent Chain:");
174        for (i, ancestor) in ctx.ancestors.iter().enumerate() {
175            let indent = "  ".repeat(i + 1);
176            println!(
177                "{}└─ {} #{}: {}",
178                indent,
179                status_icon(&ancestor.status),
180                ancestor.id,
181                ancestor.name
182            );
183        }
184    }
185
186    // Print children
187    if !ctx.children.is_empty() {
188        println!("\nChildren:");
189        for child in &ctx.children {
190            println!(
191                "  {} #{}: {}",
192                status_icon(&child.status),
193                child.id,
194                child.name
195            );
196        }
197    }
198
199    // Print siblings
200    if !ctx.siblings.is_empty() {
201        println!("\nSiblings:");
202        for sibling in &ctx.siblings {
203            println!(
204                "  {} #{}: {}",
205                status_icon(&sibling.status),
206                sibling.id,
207                sibling.name
208            );
209        }
210    }
211
212    // Print dependencies (blocking tasks)
213    if !ctx.dependencies.blocking_tasks.is_empty() {
214        println!("\nDepends on:");
215        for dep in &ctx.dependencies.blocking_tasks {
216            println!("  {} #{}: {}", status_icon(&dep.status), dep.id, dep.name);
217        }
218    }
219
220    // Print dependents (blocked by tasks)
221    if !ctx.dependencies.blocked_by_tasks.is_empty() {
222        println!("\nBlocks:");
223        for dep in &ctx.dependencies.blocked_by_tasks {
224            println!("  {} #{}: {}", status_icon(&dep.status), dep.id, dep.name);
225        }
226    }
227
228    println!();
229}
230
231/// Print events summary (recent events with count)
232pub fn print_events_summary(summary: &EventsSummary) {
233    println!("Events ({}):", summary.total_count);
234    for event in summary.recent_events.iter().take(10) {
235        println!(
236            "  [{}] {} — {}",
237            event.log_type,
238            event.timestamp.format("%Y-%m-%d %H:%M:%S"),
239            event.discussion_data
240        );
241    }
242}
243
244/// Check if query is a `#ID` format (e.g., `"#123"`, `"#1"`).
245/// Returns `Some(id)` if it is a task ID query, `None` otherwise.
246pub fn parse_task_id_query(query: &str) -> Option<i64> {
247    let query = query.trim();
248    if !query.starts_with('#') || query.len() < 2 {
249        return None;
250    }
251    query[1..].parse::<i64>().ok()
252}
253
254/// Check if query is a status keyword combination (`todo`, `doing`, `done`).
255/// Returns `Some(statuses)` if all words are valid status keywords, `None` otherwise.
256pub fn parse_status_keywords(query: &str) -> Option<Vec<String>> {
257    let query_lower = query.to_lowercase();
258    let words: Vec<&str> = query_lower.split_whitespace().collect();
259
260    if words.is_empty() {
261        return None;
262    }
263
264    let valid_statuses = ["todo", "doing", "done"];
265    let mut statuses: Vec<String> = Vec::new();
266
267    for word in words {
268        if valid_statuses.contains(&word) {
269            if !statuses.iter().any(|s| s == word) {
270                statuses.push(word.to_string());
271            }
272        } else {
273            return None;
274        }
275    }
276
277    Some(statuses)
278}
279
280/// Parse metadata key=value strings into a JSON object.
281/// "key=value" sets a key, "key=" deletes a key.
282pub fn parse_metadata(pairs: &[String]) -> Result<serde_json::Value> {
283    let mut map = serde_json::Map::new();
284    for pair in pairs {
285        if let Some(eq_pos) = pair.find('=') {
286            let key = pair[..eq_pos].trim().to_string();
287            let value = pair[eq_pos + 1..].trim().to_string();
288            if key.is_empty() {
289                return Err(IntentError::InvalidInput(format!(
290                    "Invalid metadata: empty key in '{}'",
291                    pair
292                )));
293            }
294            if value.is_empty() {
295                // "key=" means delete
296                map.insert(key, serde_json::Value::Null);
297            } else {
298                map.insert(key, serde_json::Value::String(value));
299            }
300        } else {
301            return Err(IntentError::InvalidInput(format!(
302                "Invalid metadata format: '{}'. Expected 'key=value'",
303                pair
304            )));
305        }
306    }
307    Ok(serde_json::Value::Object(map))
308}
309
310/// Merge new metadata into existing metadata JSON string.
311/// Null values in new_meta mean "delete this key".
312pub fn merge_metadata(existing: Option<&str>, new_meta: &serde_json::Value) -> Option<String> {
313    let mut base: serde_json::Map<String, serde_json::Value> = existing
314        .and_then(|s| serde_json::from_str(s).ok())
315        .unwrap_or_default();
316
317    if let serde_json::Value::Object(new_map) = new_meta {
318        for (key, value) in new_map {
319            if value.is_null() {
320                base.remove(key);
321            } else {
322                base.insert(key.clone(), value.clone());
323            }
324        }
325    }
326
327    if base.is_empty() {
328        None
329    } else {
330        Some(serde_json::to_string(&base).unwrap_or_default())
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::db::models::{Task, TaskContext, TaskDependencies};
338
339    // Helper function to create a test task with minimal boilerplate
340    fn create_test_task(id: i64, name: &str, status: &str, parent_id: Option<i64>) -> Task {
341        Task {
342            id,
343            name: name.to_string(),
344            status: status.to_string(),
345            spec: None,
346            parent_id,
347            priority: Some(5),
348            complexity: None,
349            first_todo_at: None,
350            first_doing_at: None,
351            first_done_at: None,
352            active_form: None,
353            owner: "human".to_string(),
354            metadata: None,
355        }
356    }
357
358    #[test]
359    fn test_get_status_badge_done() {
360        assert_eq!(get_status_badge("done"), "✓");
361    }
362
363    #[test]
364    fn test_get_status_badge_doing() {
365        assert_eq!(get_status_badge("doing"), "→");
366    }
367
368    #[test]
369    fn test_get_status_badge_todo() {
370        assert_eq!(get_status_badge("todo"), "○");
371    }
372
373    #[test]
374    fn test_get_status_badge_unknown() {
375        assert_eq!(get_status_badge("unknown"), "?");
376        assert_eq!(get_status_badge(""), "?");
377        assert_eq!(get_status_badge("invalid"), "?");
378    }
379
380    #[test]
381    fn test_status_icon() {
382        assert_eq!(status_icon("todo"), "○");
383        assert_eq!(status_icon("doing"), "●");
384        assert_eq!(status_icon("done"), "✓");
385        assert_eq!(status_icon("unknown"), "?");
386    }
387
388    #[test]
389    fn test_print_task_context_basic() {
390        let task = create_test_task(1, "Test Task", "todo", None);
391
392        let ctx = TaskContext {
393            task,
394            ancestors: vec![],
395            children: vec![],
396            siblings: vec![],
397            dependencies: TaskDependencies {
398                blocking_tasks: vec![],
399                blocked_by_tasks: vec![],
400            },
401        };
402
403        // Should not panic and should execute all branches
404        print_task_context(&ctx); // should not panic
405    }
406
407    #[test]
408    fn test_print_task_context_with_spec() {
409        let mut task = create_test_task(2, "Task with Spec", "doing", None);
410        task.spec = Some("This is a\nmulti-line\nspecification".to_string());
411
412        let ctx = TaskContext {
413            task,
414            ancestors: vec![],
415            children: vec![],
416            siblings: vec![],
417            dependencies: TaskDependencies {
418                blocking_tasks: vec![],
419                blocked_by_tasks: vec![],
420            },
421        };
422
423        print_task_context(&ctx); // should not panic
424    }
425
426    #[test]
427    fn test_print_task_context_with_children() {
428        let task = create_test_task(3, "Parent Task", "doing", None);
429        let child1 = create_test_task(4, "Child Task 1", "todo", Some(3));
430        let child2 = create_test_task(5, "Child Task 2", "done", Some(3));
431
432        let ctx = TaskContext {
433            task,
434            ancestors: vec![],
435            children: vec![child1, child2],
436            siblings: vec![],
437            dependencies: TaskDependencies {
438                blocking_tasks: vec![],
439                blocked_by_tasks: vec![],
440            },
441        };
442
443        print_task_context(&ctx); // should not panic
444    }
445
446    #[test]
447    fn test_print_task_context_with_ancestors() {
448        let task = create_test_task(6, "Nested Task", "doing", Some(7));
449        let parent = create_test_task(7, "Parent Task", "doing", None);
450
451        let ctx = TaskContext {
452            task,
453            ancestors: vec![parent],
454            children: vec![],
455            siblings: vec![],
456            dependencies: TaskDependencies {
457                blocking_tasks: vec![],
458                blocked_by_tasks: vec![],
459            },
460        };
461
462        print_task_context(&ctx); // should not panic
463    }
464
465    #[test]
466    fn test_print_task_context_with_dependencies() {
467        let task = create_test_task(8, "Task with Dependencies", "todo", None);
468        let blocker = create_test_task(9, "Blocking Task", "doing", None);
469        let blocked = create_test_task(10, "Blocked Task", "todo", None);
470
471        let ctx = TaskContext {
472            task,
473            ancestors: vec![],
474            children: vec![],
475            siblings: vec![],
476            dependencies: TaskDependencies {
477                blocking_tasks: vec![blocker],
478                blocked_by_tasks: vec![blocked],
479            },
480        };
481
482        print_task_context(&ctx); // should not panic
483    }
484
485    #[test]
486    fn test_print_task_context_with_siblings() {
487        let task = create_test_task(11, "Task with Siblings", "doing", Some(12));
488        let sibling = create_test_task(13, "Sibling Task", "todo", Some(12));
489
490        let ctx = TaskContext {
491            task,
492            ancestors: vec![],
493            children: vec![],
494            siblings: vec![sibling],
495            dependencies: TaskDependencies {
496                blocking_tasks: vec![],
497                blocked_by_tasks: vec![],
498            },
499        };
500
501        print_task_context(&ctx); // should not panic
502    }
503
504    // ============================================================================
505    // parse_task_id_query tests
506    // ============================================================================
507
508    #[test]
509    fn test_parse_task_id_query_valid() {
510        assert_eq!(parse_task_id_query("#1"), Some(1));
511        assert_eq!(parse_task_id_query("#123"), Some(123));
512        assert_eq!(parse_task_id_query("#999999"), Some(999999));
513    }
514
515    #[test]
516    fn test_parse_task_id_query_with_whitespace() {
517        assert_eq!(parse_task_id_query("  #1  "), Some(1));
518        assert_eq!(parse_task_id_query("\t#42\n"), Some(42));
519    }
520
521    #[test]
522    fn test_parse_task_id_query_invalid() {
523        assert_eq!(parse_task_id_query("123"), None);
524        assert_eq!(parse_task_id_query("task"), None);
525        assert_eq!(parse_task_id_query("#"), None);
526        assert_eq!(parse_task_id_query("#abc"), None);
527        assert_eq!(parse_task_id_query("#1a"), None);
528        assert_eq!(parse_task_id_query("#a1"), None);
529        assert_eq!(parse_task_id_query("#123 task"), None);
530        assert_eq!(parse_task_id_query("task #123"), None);
531        assert_eq!(parse_task_id_query("#-1"), Some(-1));
532        assert_eq!(parse_task_id_query(""), None);
533    }
534
535    // ============================================================================
536    // parse_status_keywords tests
537    // ============================================================================
538
539    #[test]
540    fn test_parse_status_keywords_valid() {
541        assert_eq!(
542            parse_status_keywords("todo"),
543            Some(vec!["todo".to_string()])
544        );
545        assert_eq!(
546            parse_status_keywords("doing"),
547            Some(vec!["doing".to_string()])
548        );
549        assert_eq!(
550            parse_status_keywords("done"),
551            Some(vec!["done".to_string()])
552        );
553    }
554
555    #[test]
556    fn test_parse_status_keywords_multiple() {
557        let result = parse_status_keywords("todo doing");
558        assert!(result.is_some());
559        let statuses = result.unwrap();
560        assert!(statuses.contains(&"todo".to_string()));
561        assert!(statuses.contains(&"doing".to_string()));
562    }
563
564    #[test]
565    fn test_parse_status_keywords_case_insensitive() {
566        assert_eq!(
567            parse_status_keywords("TODO"),
568            Some(vec!["todo".to_string()])
569        );
570        assert_eq!(
571            parse_status_keywords("DoInG"),
572            Some(vec!["doing".to_string()])
573        );
574    }
575
576    #[test]
577    fn test_parse_status_keywords_invalid() {
578        assert_eq!(parse_status_keywords("todo task"), None);
579        assert_eq!(parse_status_keywords("search term"), None);
580        assert_eq!(parse_status_keywords(""), None);
581        assert_eq!(parse_status_keywords("   "), None);
582    }
583
584    #[test]
585    fn test_parse_status_keywords_dedup() {
586        let result = parse_status_keywords("todo todo todo");
587        assert!(result.is_some());
588        let statuses = result.unwrap();
589        assert_eq!(statuses.len(), 1);
590        assert_eq!(statuses[0], "todo");
591    }
592}