fur_cli/commands/
thread.rs

1use std::fs;
2use std::path::Path;
3use serde_json::{Value, json};
4use clap::Parser;
5use chrono::{DateTime, Local, Utc};
6use crate::renderer::list::render_list;
7
8/// Arguments for the `thread` command
9#[derive(Parser)]
10pub struct ThreadArgs {
11    /// Thread ID or prefix to switch
12    pub id: Option<String>,
13
14    /// View all threads
15    #[arg(long)]
16    pub view: bool,
17}
18
19/// Main entry point for the `thread` command
20pub fn run_thread(args: ThreadArgs) {
21    let fur_dir = Path::new(".fur");
22    let index_path = fur_dir.join("index.json");
23
24    if !index_path.exists() {
25        eprintln!("🚨 .fur/ not found. Run `fur new` first.");
26        return;
27    }
28
29    let mut index: Value =
30        serde_json::from_str(&fs::read_to_string(&index_path).unwrap()).unwrap();
31
32    // ------------------------
33    // VIEW ALL THREADS
34    // ------------------------
35    if args.view || args.id.is_none() {
36        let empty_vec: Vec<Value> = Vec::new();
37        let threads = index["threads"].as_array().unwrap_or(&empty_vec);
38        let active = index["active_thread"].as_str().unwrap_or("");
39
40        let mut rows = Vec::new();
41        let mut active_idx = None;
42
43        // Collect thread metadata first
44        let mut thread_info = Vec::new();
45        for tid in threads {
46            if let Some(tid_str) = tid.as_str() {
47                let thread_path = fur_dir.join("threads").join(format!("{}.json", tid_str));
48                if let Ok(content) = fs::read_to_string(thread_path) {
49                    if let Ok(thread_json) = serde_json::from_str::<Value>(&content) {
50                        let title = thread_json["title"].as_str().unwrap_or("Untitled").to_string();
51                        let created_raw = thread_json["created_at"].as_str().unwrap_or("");
52                        let msg_count = thread_json["messages"]
53                            .as_array()
54                            .map(|a| a.len())
55                            .unwrap_or(0);
56
57                        // Parse created_at safely
58                        let parsed_time = DateTime::parse_from_rfc3339(created_raw)
59                            .map(|dt| dt.with_timezone(&Utc))
60                            .unwrap_or_else(|_| Utc::now());
61                        let local_time: DateTime<Local> = DateTime::from(parsed_time);
62                        let date_str = local_time.format("%Y-%m-%d").to_string();
63                        let time_str = local_time.format("%H:%M").to_string();
64
65                        thread_info.push((
66                            tid_str.to_string(),
67                            title,
68                            date_str,
69                            time_str,
70                            msg_count,
71                            parsed_time,
72                        ));
73                    }
74                }
75            }
76        }
77
78        // Sort newest → oldest
79        thread_info.sort_by(|a, b| b.5.cmp(&a.5));
80
81        // Build rows and track active index
82        for (i, (tid, title, date, time, msg_count, _)) in thread_info.iter().enumerate() {
83            let short_id = &tid[..8];
84            rows.push(vec![
85                short_id.to_string(),
86                title.to_string(),
87                format!("{} | {}", date, time),
88                msg_count.to_string(),
89            ]);
90            if tid == active {
91                active_idx = Some(i);
92            }
93        }
94
95        render_list("Threads", &["ID", "Title", "Created", "#Msgs"], rows, active_idx);
96        return;
97    }
98
99    // ------------------------
100    // SWITCH ACTIVE THREAD
101    // ------------------------
102    if let Some(tid) = args.id {
103        let empty_vec: Vec<Value> = Vec::new();
104        let threads: Vec<String> = index["threads"]
105            .as_array()
106            .unwrap_or(&empty_vec)
107            .iter()
108            .filter_map(|t| t.as_str().map(|s| s.to_string()))
109            .collect();
110
111        let mut found = threads.iter().find(|&s| s == &tid);
112        if found.is_none() {
113            let matches: Vec<&String> = threads.iter().filter(|s| s.starts_with(&tid)).collect();
114            if matches.len() == 1 {
115                found = Some(matches[0]);
116            } else if matches.len() > 1 {
117                eprintln!("❌ Ambiguous prefix '{}'. Matches: {:?}", tid, matches);
118                return;
119            }
120        }
121
122        let tid_full = match found {
123            Some(s) => s,
124            None => {
125                eprintln!("❌ Thread not found: {}", tid);
126                return;
127            }
128        };
129
130        index["active_thread"] = json!(tid_full);
131        index["current_message"] = serde_json::Value::Null;
132        fs::write(&index_path, serde_json::to_string_pretty(&index).unwrap()).unwrap();
133
134        let thread_path = fur_dir.join("threads").join(format!("{}.json", tid_full));
135        let content = fs::read_to_string(thread_path).unwrap();
136        let thread_json: Value = serde_json::from_str(&content).unwrap();
137        let title = thread_json["title"].as_str().unwrap_or("Untitled");
138
139        println!("✔️ Switched active thread to {} \"{}\"", &tid_full[..8], title);
140    }
141}