use crate::db::models::{EventsSummary, Task, TaskContext};
use crate::error::{IntentError, Result};
use std::io::{self, Read};
pub fn read_stdin() -> Result<String> {
#[cfg(windows)]
{
use encoding_rs::GBK;
let mut buffer = Vec::new();
io::stdin().read_to_end(&mut buffer)?;
if let Ok(s) = String::from_utf8(buffer.clone()) {
return Ok(s.trim().to_string());
}
let (decoded, _, had_errors) = GBK.decode(&buffer);
if !had_errors {
tracing::debug!(
"Successfully decoded stdin from GBK encoding (Chinese Windows detected)"
);
Ok(decoded.trim().to_string())
} else {
tracing::warn!(
"Failed to decode stdin from both UTF-8 and GBK, using lossy UTF-8 conversion"
);
Ok(String::from_utf8_lossy(&buffer).trim().to_string())
}
}
#[cfg(not(windows))]
{
let mut buffer = String::new();
io::stdin().read_to_string(&mut buffer)?;
Ok(buffer.trim().to_string())
}
}
pub fn get_status_badge(status: &str) -> &'static str {
match status {
"done" => "✓",
"doing" => "→",
"todo" => "○",
_ => "?",
}
}
pub fn status_icon(status: &str) -> &'static str {
match status {
"todo" => "○",
"doing" => "●",
"done" => "✓",
_ => "?",
}
}
pub fn print_task_tree(tasks: &[crate::db::models::Task]) {
use std::collections::HashMap;
let mut children_map: HashMap<Option<i64>, Vec<&crate::db::models::Task>> = HashMap::new();
for task in tasks {
children_map.entry(task.parent_id).or_default().push(task);
}
fn print_subtree(
children_map: &HashMap<Option<i64>, Vec<&crate::db::models::Task>>,
parent_id: Option<i64>,
indent: &str,
) {
if let Some(children) = children_map.get(&parent_id) {
for (i, task) in children.iter().enumerate() {
let is_last_child = i == children.len() - 1;
let connector = if indent.is_empty() {
""
} else if is_last_child {
"└─ "
} else {
"├─ "
};
let icon = status_icon(&task.status);
let priority_info = task
.priority
.map(|p| format!(" [P{}]", p))
.unwrap_or_default();
println!(
" {}{}{} #{} {}{}",
indent, connector, icon, task.id, task.name, priority_info
);
let new_indent = if indent.is_empty() {
"".to_string()
} else if is_last_child {
format!("{} ", indent)
} else {
format!("{}│ ", indent)
};
print_subtree(children_map, Some(task.id), &new_indent);
}
}
}
let task_ids: std::collections::HashSet<i64> = tasks.iter().map(|t| t.id).collect();
let roots: Vec<&crate::db::models::Task> = tasks
.iter()
.filter(|t| t.parent_id.is_none() || !task_ids.contains(&t.parent_id.unwrap_or(-1)))
.collect();
for task in &roots {
let icon = status_icon(&task.status);
let priority_info = task
.priority
.map(|p| format!(" [P{}]", p))
.unwrap_or_default();
println!(" {} #{} {}{}", icon, task.id, task.name, priority_info);
print_subtree(&children_map, Some(task.id), " ");
}
}
pub fn print_task_summary(task: &Task) {
let icon = status_icon(&task.status);
println!(" {} #{} {}", icon, task.id, task.name);
println!(" Status: {}", task.status);
if let Some(pid) = task.parent_id {
println!(" Parent: #{}", pid);
}
if let Some(p) = task.priority {
println!(" Priority: {}", p);
}
if let Some(spec) = &task.spec {
if !spec.is_empty() {
println!(" Spec: {}", spec);
}
}
println!(" Owner: {}", task.owner);
if let Some(af) = &task.active_form {
println!(" Active form: {}", af);
}
if let Some(meta) = &task.metadata {
println!(" Metadata: {}", meta);
}
}
pub fn print_task_context(ctx: &TaskContext) {
let icon = status_icon(&ctx.task.status);
println!("\n{} Task #{}: {}", icon, ctx.task.id, ctx.task.name);
println!("Status: {}", ctx.task.status);
if let Some(spec) = &ctx.task.spec {
println!("\nSpec:");
for line in spec.lines() {
println!(" {}", line);
}
}
if !ctx.ancestors.is_empty() {
println!("\nParent Chain:");
for (i, ancestor) in ctx.ancestors.iter().enumerate() {
let indent = " ".repeat(i + 1);
println!(
"{}└─ {} #{}: {}",
indent,
status_icon(&ancestor.status),
ancestor.id,
ancestor.name
);
}
}
if !ctx.children.is_empty() {
println!("\nChildren:");
for child in &ctx.children {
println!(
" {} #{}: {}",
status_icon(&child.status),
child.id,
child.name
);
}
}
if !ctx.siblings.is_empty() {
println!("\nSiblings:");
for sibling in &ctx.siblings {
println!(
" {} #{}: {}",
status_icon(&sibling.status),
sibling.id,
sibling.name
);
}
}
if !ctx.dependencies.blocking_tasks.is_empty() {
println!("\nDepends on:");
for dep in &ctx.dependencies.blocking_tasks {
println!(" {} #{}: {}", status_icon(&dep.status), dep.id, dep.name);
}
}
if !ctx.dependencies.blocked_by_tasks.is_empty() {
println!("\nBlocks:");
for dep in &ctx.dependencies.blocked_by_tasks {
println!(" {} #{}: {}", status_icon(&dep.status), dep.id, dep.name);
}
}
println!();
}
pub fn print_events_summary(summary: &EventsSummary) {
println!("Events ({}):", summary.total_count);
for event in summary.recent_events.iter().take(10) {
println!(
" [{}] {} — {}",
event.log_type,
event.timestamp.format("%Y-%m-%d %H:%M:%S"),
event.discussion_data
);
}
}
pub fn parse_task_id_query(query: &str) -> Option<i64> {
let query = query.trim();
if !query.starts_with('#') || query.len() < 2 {
return None;
}
query[1..].parse::<i64>().ok()
}
pub fn parse_status_keywords(query: &str) -> Option<Vec<String>> {
let query_lower = query.to_lowercase();
let words: Vec<&str> = query_lower.split_whitespace().collect();
if words.is_empty() {
return None;
}
let valid_statuses = ["todo", "doing", "done"];
let mut statuses: Vec<String> = Vec::new();
for word in words {
if valid_statuses.contains(&word) {
if !statuses.iter().any(|s| s == word) {
statuses.push(word.to_string());
}
} else {
return None;
}
}
Some(statuses)
}
pub fn parse_metadata(pairs: &[String]) -> Result<serde_json::Value> {
let mut map = serde_json::Map::new();
for pair in pairs {
if let Some(eq_pos) = pair.find('=') {
let key = pair[..eq_pos].trim().to_string();
let value = pair[eq_pos + 1..].trim().to_string();
if key.is_empty() {
return Err(IntentError::InvalidInput(format!(
"Invalid metadata: empty key in '{}'",
pair
)));
}
if value.is_empty() {
map.insert(key, serde_json::Value::Null);
} else {
map.insert(key, serde_json::Value::String(value));
}
} else {
return Err(IntentError::InvalidInput(format!(
"Invalid metadata format: '{}'. Expected 'key=value'",
pair
)));
}
}
Ok(serde_json::Value::Object(map))
}
pub fn merge_metadata(existing: Option<&str>, new_meta: &serde_json::Value) -> Option<String> {
let mut base: serde_json::Map<String, serde_json::Value> = existing
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default();
if let serde_json::Value::Object(new_map) = new_meta {
for (key, value) in new_map {
if value.is_null() {
base.remove(key);
} else {
base.insert(key.clone(), value.clone());
}
}
}
if base.is_empty() {
None
} else {
Some(serde_json::to_string(&base).unwrap_or_default())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::models::{Task, TaskContext, TaskDependencies};
fn create_test_task(id: i64, name: &str, status: &str, parent_id: Option<i64>) -> Task {
Task {
id,
name: name.to_string(),
status: status.to_string(),
spec: None,
parent_id,
priority: Some(5),
complexity: None,
first_todo_at: None,
first_doing_at: None,
first_done_at: None,
active_form: None,
owner: "human".to_string(),
metadata: None,
}
}
#[test]
fn test_get_status_badge_done() {
assert_eq!(get_status_badge("done"), "✓");
}
#[test]
fn test_get_status_badge_doing() {
assert_eq!(get_status_badge("doing"), "→");
}
#[test]
fn test_get_status_badge_todo() {
assert_eq!(get_status_badge("todo"), "○");
}
#[test]
fn test_get_status_badge_unknown() {
assert_eq!(get_status_badge("unknown"), "?");
assert_eq!(get_status_badge(""), "?");
assert_eq!(get_status_badge("invalid"), "?");
}
#[test]
fn test_status_icon() {
assert_eq!(status_icon("todo"), "○");
assert_eq!(status_icon("doing"), "●");
assert_eq!(status_icon("done"), "✓");
assert_eq!(status_icon("unknown"), "?");
}
#[test]
fn test_print_task_context_basic() {
let task = create_test_task(1, "Test Task", "todo", None);
let ctx = TaskContext {
task,
ancestors: vec![],
children: vec![],
siblings: vec![],
dependencies: TaskDependencies {
blocking_tasks: vec![],
blocked_by_tasks: vec![],
},
};
print_task_context(&ctx); }
#[test]
fn test_print_task_context_with_spec() {
let mut task = create_test_task(2, "Task with Spec", "doing", None);
task.spec = Some("This is a\nmulti-line\nspecification".to_string());
let ctx = TaskContext {
task,
ancestors: vec![],
children: vec![],
siblings: vec![],
dependencies: TaskDependencies {
blocking_tasks: vec![],
blocked_by_tasks: vec![],
},
};
print_task_context(&ctx); }
#[test]
fn test_print_task_context_with_children() {
let task = create_test_task(3, "Parent Task", "doing", None);
let child1 = create_test_task(4, "Child Task 1", "todo", Some(3));
let child2 = create_test_task(5, "Child Task 2", "done", Some(3));
let ctx = TaskContext {
task,
ancestors: vec![],
children: vec![child1, child2],
siblings: vec![],
dependencies: TaskDependencies {
blocking_tasks: vec![],
blocked_by_tasks: vec![],
},
};
print_task_context(&ctx); }
#[test]
fn test_print_task_context_with_ancestors() {
let task = create_test_task(6, "Nested Task", "doing", Some(7));
let parent = create_test_task(7, "Parent Task", "doing", None);
let ctx = TaskContext {
task,
ancestors: vec![parent],
children: vec![],
siblings: vec![],
dependencies: TaskDependencies {
blocking_tasks: vec![],
blocked_by_tasks: vec![],
},
};
print_task_context(&ctx); }
#[test]
fn test_print_task_context_with_dependencies() {
let task = create_test_task(8, "Task with Dependencies", "todo", None);
let blocker = create_test_task(9, "Blocking Task", "doing", None);
let blocked = create_test_task(10, "Blocked Task", "todo", None);
let ctx = TaskContext {
task,
ancestors: vec![],
children: vec![],
siblings: vec![],
dependencies: TaskDependencies {
blocking_tasks: vec![blocker],
blocked_by_tasks: vec![blocked],
},
};
print_task_context(&ctx); }
#[test]
fn test_print_task_context_with_siblings() {
let task = create_test_task(11, "Task with Siblings", "doing", Some(12));
let sibling = create_test_task(13, "Sibling Task", "todo", Some(12));
let ctx = TaskContext {
task,
ancestors: vec![],
children: vec![],
siblings: vec![sibling],
dependencies: TaskDependencies {
blocking_tasks: vec![],
blocked_by_tasks: vec![],
},
};
print_task_context(&ctx); }
#[test]
fn test_parse_task_id_query_valid() {
assert_eq!(parse_task_id_query("#1"), Some(1));
assert_eq!(parse_task_id_query("#123"), Some(123));
assert_eq!(parse_task_id_query("#999999"), Some(999999));
}
#[test]
fn test_parse_task_id_query_with_whitespace() {
assert_eq!(parse_task_id_query(" #1 "), Some(1));
assert_eq!(parse_task_id_query("\t#42\n"), Some(42));
}
#[test]
fn test_parse_task_id_query_invalid() {
assert_eq!(parse_task_id_query("123"), None);
assert_eq!(parse_task_id_query("task"), None);
assert_eq!(parse_task_id_query("#"), None);
assert_eq!(parse_task_id_query("#abc"), None);
assert_eq!(parse_task_id_query("#1a"), None);
assert_eq!(parse_task_id_query("#a1"), None);
assert_eq!(parse_task_id_query("#123 task"), None);
assert_eq!(parse_task_id_query("task #123"), None);
assert_eq!(parse_task_id_query("#-1"), Some(-1));
assert_eq!(parse_task_id_query(""), None);
}
#[test]
fn test_parse_status_keywords_valid() {
assert_eq!(
parse_status_keywords("todo"),
Some(vec!["todo".to_string()])
);
assert_eq!(
parse_status_keywords("doing"),
Some(vec!["doing".to_string()])
);
assert_eq!(
parse_status_keywords("done"),
Some(vec!["done".to_string()])
);
}
#[test]
fn test_parse_status_keywords_multiple() {
let result = parse_status_keywords("todo doing");
assert!(result.is_some());
let statuses = result.unwrap();
assert!(statuses.contains(&"todo".to_string()));
assert!(statuses.contains(&"doing".to_string()));
}
#[test]
fn test_parse_status_keywords_case_insensitive() {
assert_eq!(
parse_status_keywords("TODO"),
Some(vec!["todo".to_string()])
);
assert_eq!(
parse_status_keywords("DoInG"),
Some(vec!["doing".to_string()])
);
}
#[test]
fn test_parse_status_keywords_invalid() {
assert_eq!(parse_status_keywords("todo task"), None);
assert_eq!(parse_status_keywords("search term"), None);
assert_eq!(parse_status_keywords(""), None);
assert_eq!(parse_status_keywords(" "), None);
}
#[test]
fn test_parse_status_keywords_dedup() {
let result = parse_status_keywords("todo todo todo");
assert!(result.is_some());
let statuses = result.unwrap();
assert_eq!(statuses.len(), 1);
assert_eq!(statuses[0], "todo");
}
}