Skip to main content

fur_cli/commands/
tree.rs

1use crate::avatars::resolve_avatar;
2use clap::Parser;
3use colored::*;
4use serde_json::Value;
5use std::collections::HashMap;
6use std::fs;
7use std::path::Path;
8
9#[derive(Parser, Clone)]
10pub struct TreeArgs {
11    #[clap(skip)]
12    pub conversation_override: Option<String>,
13}
14
15pub fn run_tree(args: TreeArgs) {
16    let fur_dir = Path::new(".fur");
17    let index_path = fur_dir.join("index.json");
18
19    if !index_path.exists() {
20        eprintln!(
21            "{}",
22            "🚨 .fur/ not found. Run `fur new` first.".red().bold()
23        );
24        return;
25    }
26
27    // Load index and conversation
28    let index_data: Value = serde_json::from_str(
29        &crate::security::io::read_text_file(&index_path)
30            .expect("❌ Project locked. Run `fur unlock`."),
31    )
32    .unwrap();
33
34    let conversation_id = if let Some(ref override_id) = args.conversation_override {
35        override_id
36    } else {
37        index_data["active_thread"].as_str().unwrap_or("")
38    };
39    let convo_path = fur_dir
40        .join("threads")
41        .join(format!("{}.json", conversation_id));
42    let conversation_data: Value = serde_json::from_str(
43        &crate::security::io::read_text_file(&convo_path)
44            .expect("❌ Project locked. Run `fur unlock`."),
45    )
46    .unwrap();
47
48    // Load avatars.json once
49    let avatars: Value = serde_json::from_str(
50        &fs::read_to_string(fur_dir.join("avatars.json")).unwrap_or_else(|_| "{}".to_string()),
51    )
52    .unwrap_or(serde_json::json!({}));
53
54    println!(
55        "{} {}",
56        "🌳 Conversation Tree:".bold().cyan(),
57        conversation_data["title"]
58            .as_str()
59            .unwrap_or("Untitled")
60            .green()
61            .bold()
62    );
63
64    if let Some(messages) = conversation_data["messages"].as_array() {
65        let id_to_message = load_conversation_messages(&fur_dir, &conversation_data);
66        for (idx, msg_id) in messages.iter().enumerate() {
67            if let Some(mid) = msg_id.as_str() {
68                render_message(&id_to_message, mid, "", idx == messages.len() - 1, &avatars);
69            }
70        }
71    }
72}
73
74fn load_conversation_messages(fur_dir: &Path, conversation: &Value) -> HashMap<String, Value> {
75    let mut id_to_message = HashMap::new();
76    let mut to_visit: Vec<String> = conversation["messages"]
77        .as_array()
78        .unwrap_or(&vec![])
79        .iter()
80        .filter_map(|id| id.as_str().map(|s| s.to_string()))
81        .collect();
82
83    while let Some(mid) = to_visit.pop() {
84        let path = fur_dir.join("messages").join(format!("{}.json", mid));
85        if let Some(content) = crate::security::io::read_text_file(&path) {
86            if let Ok(json) = serde_json::from_str::<Value>(&content) {
87                // enqueue children + branches
88                if let Some(children) = json["children"].as_array() {
89                    for c in children {
90                        if let Some(cid) = c.as_str() {
91                            to_visit.push(cid.to_string());
92                        }
93                    }
94                }
95                if let Some(branches) = json["branches"].as_array() {
96                    for block in branches {
97                        if let Some(arr) = block.as_array() {
98                            for c in arr {
99                                if let Some(cid) = c.as_str() {
100                                    to_visit.push(cid.to_string());
101                                }
102                            }
103                        }
104                    }
105                }
106                id_to_message.insert(mid.clone(), json);
107            }
108        }
109    }
110    id_to_message
111}
112
113fn render_message(
114    id_to_message: &HashMap<String, Value>,
115    msg_id: &str,
116    prefix: &str,
117    is_last: bool,
118    avatars: &Value,
119) {
120    if let Some(msg) = id_to_message.get(msg_id) {
121        // build tree connector
122        let branch_symbol = if is_last { "└──" } else { "├──" };
123        let tree_prefix = format!("{}{}", prefix, branch_symbol.bright_green());
124
125        let avatar_key = msg["avatar"].as_str().unwrap_or("???");
126        let (name, emoji) = resolve_avatar(avatars, avatar_key);
127
128        let text = crate::renderer::utils::preview_of(msg, 60);
129
130        let id_display = msg_id[..8].to_string();
131
132        if msg.get("markdown").is_some() {
133            println!(
134                "{} {} {} {} {} {}",
135                tree_prefix,
136                "[Root]".cyan(),
137                emoji.yellow(),
138                format!("[{}]", name).bright_yellow(),
139                text.white(),
140                format!("📄 {}", id_display).magenta()
141            );
142        } else {
143            println!(
144                "{} {} {} {} {}",
145                tree_prefix,
146                "[Root]".cyan(),
147                emoji.yellow(),
148                format!("[{}]", name).bright_yellow(),
149                format!("{} {}", text.white(), id_display.bright_black())
150            );
151        }
152
153        // Lifetime-safe empty vec
154        let empty: Vec<Value> = Vec::new();
155        let children = msg["children"].as_array().unwrap_or(&empty);
156        let branches = msg["branches"].as_array().unwrap_or(&empty);
157
158        // merge both: if branches exist, prefer them
159        if !branches.is_empty() {
160            for (_b_idx, branch) in branches.iter().enumerate() {
161                if let Some(arr) = branch.as_array() {
162                    for (i, child_id) in arr.iter().enumerate() {
163                        if let Some(cid) = child_id.as_str() {
164                            let new_prefix = format!(
165                                "{}{}   ",
166                                prefix,
167                                if is_last { "    " } else { "│  " }.bright_green()
168                            );
169                            render_message(
170                                id_to_message,
171                                cid,
172                                &new_prefix,
173                                i == arr.len() - 1,
174                                avatars,
175                            );
176                        }
177                    }
178                }
179            }
180        } else {
181            for (i, child_id) in children.iter().enumerate() {
182                if let Some(cid) = child_id.as_str() {
183                    let new_prefix = format!(
184                        "{}{}   ",
185                        prefix,
186                        if is_last { "    " } else { "│  " }.bright_green()
187                    );
188                    render_message(
189                        id_to_message,
190                        cid,
191                        &new_prefix,
192                        i == children.len() - 1,
193                        avatars,
194                    );
195                }
196            }
197        }
198    }
199}