fur_cli/commands/
status.rs

1use std::fs;
2use std::path::Path;
3use serde_json::{Value, json};
4use std::collections::HashMap;
5use colored::*;
6use crate::frs::avatars::resolve_avatar;
7use clap::Parser;
8
9#[derive(Parser, Debug)]
10pub struct StatusArgs {
11    /// Optional thread override (used by `fur run` for ephemeral runs)
12    #[clap(skip)]
13    pub thread_override: Option<String>,
14}
15
16pub fn run_status(args: StatusArgs) {
17    let fur_dir = Path::new(".fur");
18    let index_path = fur_dir.join("index.json");
19
20    if !index_path.exists() {
21        eprintln!("{}", "🚨 .fur/ not found. Run `fur new` first.".red().bold());
22        return;
23    }
24
25    // Load avatars once
26    let avatars: Value = serde_json::from_str(
27        &fs::read_to_string(fur_dir.join("avatars.json")).unwrap_or_else(|_| "{}".to_string())
28    ).unwrap_or(json!({}));
29
30    // Load index + thread
31    let (index, mut thread, mut current_msg_id) = load_index_and_thread(&fur_dir);
32
33    // --- use override if present
34    if let Some(ref tid) = args.thread_override {
35        let thread_path = fur_dir.join("tmp").join(format!("{}.json", tid));
36        if let Ok(content) = fs::read_to_string(&thread_path) {
37            if let Ok(tmp_thread) = serde_json::from_str::<Value>(&content) {
38                thread = tmp_thread;
39            }
40        }
41    }
42
43    // Preload all messages
44    let id_to_message = build_id_to_message(&fur_dir, &thread);
45
46    // Default current message if empty
47    if current_msg_id.is_empty() {
48        if let Some(first) = thread["messages"].as_array().and_then(|arr| arr.get(0)) {
49            if let Some(fid) = first.as_str() {
50                current_msg_id = fid.to_string();
51            }
52        }
53    }
54
55    // Active thread
56    println!(
57        "{} {} {}",
58        "Active thread:".bright_cyan().bold(),
59        thread["title"].as_str().unwrap_or("Untitled").bright_green().bold(),
60        format!("({})", index["active_thread"].as_str().unwrap_or("?")).bright_black()
61    );
62
63    // Current message
64    println!(
65        "{} {}",
66        "Current message:".bright_cyan().bold(),
67        current_msg_id.bright_black() // hash dim gray
68    );
69    println!("{}", "─────────────────────────────".bright_black());
70
71    // Print lineage (ancestors)
72    print_lineage(&id_to_message, &current_msg_id, &avatars);
73
74    println!("{}", "─────────────────────────────".bright_black());
75    println!("{}", "Next messages from here:".bright_magenta().bold());
76
77    // Print children/siblings
78    print_next_messages(&id_to_message, &thread, &current_msg_id, &avatars);
79}
80
81/// Load index.json and active thread
82fn load_index_and_thread(fur_dir: &Path) -> (Value, Value, String) {
83    let index_path = fur_dir.join("index.json");
84    let index: Value =
85        serde_json::from_str(&fs::read_to_string(&index_path).expect("❌ Cannot read index.json"))
86            .unwrap();
87
88    let thread_id = index["active_thread"].as_str().unwrap_or("");
89    let current_msg_id = index["current_message"].as_str().unwrap_or("").to_string();
90
91    let thread_path = fur_dir.join("threads").join(format!("{}.json", thread_id));
92    let thread: Value =
93        serde_json::from_str(&fs::read_to_string(&thread_path).expect("❌ Cannot read thread"))
94            .unwrap();
95
96    (index, thread, current_msg_id)
97}
98
99/// Preload all messages into a HashMap
100fn build_id_to_message(fur_dir: &Path, thread: &Value) -> HashMap<String, Value> {
101    let mut id_to_message = HashMap::new();
102    let mut to_visit: Vec<String> = thread["messages"]
103        .as_array()
104        .unwrap_or(&vec![])
105        .iter()
106        .filter_map(|id| id.as_str().map(|s| s.to_string()))
107        .collect();
108
109    while let Some(mid) = to_visit.pop() {
110        let path = fur_dir.join("messages").join(format!("{}.json", mid));
111        if let Ok(content) = fs::read_to_string(path) {
112            if let Ok(json) = serde_json::from_str::<Value>(&content) {
113                // enqueue children + branches
114                if let Some(children) = json["children"].as_array() {
115                    for c in children {
116                        if let Some(cid) = c.as_str() {
117                            to_visit.push(cid.to_string());
118                        }
119                    }
120                }
121                if let Some(branches) = json["branches"].as_array() {
122                    for block in branches {
123                        if let Some(arr) = block.as_array() {
124                            for c in arr {
125                                if let Some(cid) = c.as_str() {
126                                    to_visit.push(cid.to_string());
127                                }
128                            }
129                        }
130                    }
131                }
132                id_to_message.insert(mid.clone(), json);
133            }
134        }
135    }
136    id_to_message
137}
138
139/// Show lineage (ancestors)
140fn print_lineage(id_to_message: &HashMap<String, Value>, current_msg_id: &str, avatars: &Value) {
141    let mut lineage = vec![];
142    let mut current = current_msg_id.to_string();
143    while let Some(msg) = id_to_message.get(&current) {
144        lineage.push(current.clone());
145        match msg["parent"].as_str() {
146            Some(parent_id) => current = parent_id.to_string(),
147            None => break,
148        }
149    }
150    lineage.reverse();
151
152    for id in &lineage {
153        if let Some(msg) = id_to_message.get(id) {
154            let avatar_key = msg["avatar"].as_str().unwrap_or("???");
155            let (name, emoji) = resolve_avatar(avatars, avatar_key);
156            let text = msg.get("text").and_then(|v| v.as_str()).unwrap_or_else(|| {
157                msg.get("markdown")
158                    .and_then(|v| v.as_str())
159                    .unwrap_or("<no content>")
160            });
161
162            let preview = text.lines().next().unwrap_or("").chars().take(40).collect::<String>();
163            let marker = if *id == current_msg_id { "(current)".cyan().bold() } else { "✅".green() };
164            let id_display = &id[..8];
165            let branch_label = compute_branch_label(id, id_to_message);
166
167            if msg.get("markdown").is_some() {
168                println!(
169                    "{} {} {} {} {} {}",
170                    preview.white(),
171                    emoji,
172                    format!("[{}]", name).bright_yellow().bold(),
173                    id_display.bright_black(),
174                    branch_label.bright_green(),
175                    marker
176                );
177            } else {
178                println!(
179                    "{} {} {} {} {} {}",
180                    preview.white(),
181                    emoji,
182                    format!("[{}]", name).bright_yellow().bold(),
183                    id_display.bright_black(),
184                    branch_label.bright_green(),
185                    marker
186                );
187            }
188        }
189    }
190}
191
192/// Show children/siblings after current
193fn print_next_messages(id_to_message: &HashMap<String, Value>, thread: &Value, current_msg_id: &str, avatars: &Value) {
194    if let Some(curr_msg) = id_to_message.get(current_msg_id) {
195        let mut next_ids: Vec<String> = vec![];
196
197        // children
198        if let Some(children) = curr_msg["children"].as_array() {
199            next_ids.extend(children.iter().filter_map(|c| c.as_str().map(|s| s.to_string())));
200        }
201
202        // siblings
203        if next_ids.is_empty() {
204            if let Some(parent_id) = curr_msg["parent"].as_str() {
205                if let Some(parent) = id_to_message.get(parent_id) {
206                    if let Some(branches) = parent["branches"].as_array() {
207                        for block in branches {
208                            if let Some(arr) = block.as_array() {
209                                if let Some(pos) = arr.iter().position(|c| c.as_str() == Some(current_msg_id)) {
210                                    for sib in arr.iter().skip(pos + 1) {
211                                        if let Some(cid) = sib.as_str() {
212                                            next_ids.push(cid.to_string());
213                                        }
214                                    }
215                                }
216                            }
217                        }
218                    }
219                }
220            }
221        }
222
223        // top-level siblings
224        if next_ids.is_empty() && curr_msg["parent"].is_null() {
225            if let Some(thread_msgs) = thread["messages"].as_array() {
226                if let Some(pos) = thread_msgs.iter().position(|id| id.as_str() == Some(current_msg_id)) {
227                    for sib in thread_msgs.iter().skip(pos + 1) {
228                        if let Some(cid) = sib.as_str() {
229                            next_ids.push(cid.to_string());
230                        }
231                    }
232                }
233            }
234        }
235
236        if next_ids.is_empty() {
237            println!("{}", "(No further messages in this branch.)".bright_black());
238        } else {
239            for child_id in next_ids {
240                if let Some(msg) = id_to_message.get(&child_id) {
241                    let avatar_key = msg["avatar"].as_str().unwrap_or("???");
242                    let (name, emoji) = resolve_avatar(avatars, avatar_key);
243                    let text = msg.get("text").and_then(|v| v.as_str()).unwrap_or_else(|| {
244                        msg.get("markdown")
245                            .and_then(|v| v.as_str())
246                            .unwrap_or("<no content>")
247                    });
248
249                    let preview = text.lines().next().unwrap_or("").chars().take(40).collect::<String>();
250                    let id_display = &child_id[..8];
251                    let branch_label = compute_branch_label(&child_id, id_to_message);
252
253                    println!(
254                        "🔹 {} {} {} {} {}",
255                        preview.white(),
256                        emoji,
257                        format!("[{}]", name).bright_yellow().bold(),
258                        id_display.bright_black(),
259                        branch_label.bright_green()
260                    );
261                }
262            }
263        }
264    } else {
265        println!("{}", "(No current message found.)".red());
266    }
267}
268
269/// Walks backwards from a message to compute its branch path label
270fn compute_branch_label(msg_id: &str, id_to_message: &HashMap<String, Value>) -> String {
271    let mut labels = vec![];
272    let mut current_id = msg_id;
273
274    while let Some(msg) = id_to_message.get(current_id) {
275        if let Some(parent_id) = msg["parent"].as_str() {
276            if let Some(parent) = id_to_message.get(parent_id) {
277                if let Some(branches) = parent["branches"].as_array() {
278                    for (b_idx, branch) in branches.iter().enumerate() {
279                        if let Some(arr) = branch.as_array() {
280                            if arr.iter().any(|c| c.as_str() == Some(current_id)) {
281                                labels.push(format!("{}", b_idx + 1));
282                            }
283                        }
284                    }
285                }
286            }
287            current_id = parent_id;
288        } else {
289            break;
290        }
291    }
292
293    labels.reverse();
294    if labels.is_empty() {
295        "[Root]".to_string()
296    } else {
297        format!("[Branch {}]", labels.join("."))
298    }
299}