Skip to content

Commit 0048da4

Browse files
committed
v1.2.2 :: feature addition; added insert feature to msg command; now can insert long/short messages in a conversation!
1 parent c6388a9 commit 0048da4

File tree

5 files changed

+236
-83
lines changed

5 files changed

+236
-83
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
[package]
22
name = "fur-cli"
3-
version = "1.2.1"
3+
version = "1.2.2"
44
edition = "2021"
55
authors = ["Andrew Garcia"]
66
description = "Turn your AI chats into a durable, local-first diary. Save messages, attach notes, organize conversations, and stop losing context every time the model forgets you exist."
77
license = "MIT"
88
readme = "README.md"
99
repository = "https://github.com/andrewrgarcia/fur-cli"
10+
homepage = "https://furchats.vercel.app"
1011
keywords = ["cli", "notes", "ai", "chat", "versioning"]
1112
categories = ["command-line-utilities"]
1213

src/commands/message.rs

Lines changed: 104 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use serde_json::{Value, json};
33
use std::fs;
44
use std::io::{Write};
55
use std::path::Path;
6+
use crate::helpers::insertion::run_insert;
67

78
/// Subcommand: `fur msg`
89
#[derive(Parser, Debug)]
@@ -11,9 +12,14 @@ pub struct MsgArgs {
1112
#[arg(index = 1)]
1213
pub id_prefix: Option<String>,
1314

14-
/// Second positional: text
15-
#[arg(index = 2)]
16-
pub text_value: Option<String>,
15+
16+
/// Insert before target
17+
#[arg(long)]
18+
pub pre: bool,
19+
20+
/// Insert after target
21+
#[arg(long)]
22+
pub post: bool,
1723

1824
#[arg(long)]
1925
pub edit: bool,
@@ -29,16 +35,41 @@ pub struct MsgArgs {
2935

3036
#[arg(long)]
3137
pub interactive: bool,
38+
39+
/// Everything *after* the ID
40+
#[arg(index = 2, trailing_var_arg = true)]
41+
pub rest: Vec<String>,
42+
3243
}
3344

45+
3446
/// Entry point
3547
pub fn run_msg(args: MsgArgs) {
48+
3649
if args.delete {
3750
return run_delete(args);
3851
}
39-
run_edit(args);
52+
53+
// INSERT BEFORE
54+
if args.pre {
55+
return run_insert(&args, true);
56+
}
57+
58+
// INSERT AFTER
59+
if args.post {
60+
return run_insert(&args, false);
61+
}
62+
63+
if args.edit {
64+
return run_edit(args);
65+
}
66+
67+
eprintln!("❌ msg requires: --pre | --post | --edit | --delete");
4068
}
4169

70+
71+
72+
4273
//
4374
// ======================================================
4475
// DELETE LOGIC
@@ -55,7 +86,8 @@ fn run_delete(args: MsgArgs) {
5586

5687
let mut buf = String::new();
5788
std::io::stdin().read_line(&mut buf).unwrap();
58-
if !["y", "Y", "yes", "YES"].contains(&buf.trim()) {
89+
90+
if !["y","Y","yes","YES"].contains(&buf.trim()) {
5991
println!("❌ Cancelled.");
6092
return;
6193
}
@@ -74,35 +106,32 @@ fn run_delete(args: MsgArgs) {
74106
//
75107

76108
fn run_edit(args: MsgArgs) {
77-
let (id_opt, mut new_text) =
78-
classify_id_and_text(args.id_prefix, args.text_value);
109+
let (id_opt, mut text_opt) = classify_id_or_text(&args);
79110

80111
// Final target message ID
81-
let mid = id_opt.unwrap_or_else(|| resolve_target_message(None));
112+
let id = id_opt.unwrap_or_else(|| resolve_target_message(None));
82113

83114
let fur = Path::new(".fur");
84-
let msg_path = fur.join("messages").join(format!("{}.json", mid));
115+
let msg_path = fur.join("messages").join(format!("{}.json", id));
85116

86117
let mut msg: Value =
87118
serde_json::from_str(&fs::read_to_string(&msg_path).unwrap()).unwrap();
88119

89120
// Interactive override
90121
if args.interactive {
91-
let edited = run_interactive_editor(
92-
msg["text"].as_str().unwrap_or_default()
93-
);
94-
new_text = Some(edited);
122+
let edited = run_interactive_editor(msg["text"].as_str().unwrap_or_default());
123+
text_opt = Some(edited);
95124
}
96125

97126
// Apply text
98-
if let Some(t) = new_text {
127+
if let Some(t) = text_opt {
99128
msg["text"] = json!(t);
100129
msg["markdown"] = json!(null);
101130
}
102131

103132
// Apply markdown
104-
if let Some(fpath) = args.file {
105-
msg["markdown"] = json!(fpath);
133+
if let Some(fp) = args.file {
134+
msg["markdown"] = json!(fp);
106135
msg["text"] = json!(null);
107136
}
108137

@@ -113,66 +142,64 @@ fn run_edit(args: MsgArgs) {
113142

114143
write_json(&msg_path, &msg);
115144

116-
println!("✏️ Edited {}", &mid[..8]);
145+
println!("✏️ Edited {}", &id[..8]);
117146
}
118147

148+
149+
119150
//
120151
// ======================================================
121-
// POSITONAL ARG PARSING LOGIC
152+
// POSITONAL ID RESOLUTION
122153
// ======================================================
123154
//
124155

125-
/// Detect if a value looks like a message ID prefix.
126-
/// Returns Some(full_id) or None.
127-
fn detect_id(x: &Option<String>) -> Option<String> {
128-
let Some(val) = x else { return None; };
156+
/// Detect if value looks like an ID prefix.
157+
pub fn detect_id(x: &Option<String>) -> Option<String> {
158+
let Some(val) = x else { return None };
129159

130-
// positional that begins with "--" cannot be ID
131160
if val.starts_with("--") {
132161
return None;
133162
}
134163

135-
// Try to match existing prefix
136-
if let Some(id) = resolve_prefix_if_exists(val) {
137-
return Some(id);
138-
}
139-
140-
None
164+
resolve_prefix_if_exists(val)
141165
}
142166

143-
/// Interpret positionals into (id, text)
144-
///
145-
/// Rules:
146-
/// - If first positional matches a prefix → ID
147-
/// - Second positional always text
148-
/// - If first positional does NOT match → treat as text
149-
fn classify_id_and_text(
150-
id_prefix: Option<String>,
151-
text_value: Option<String>
152-
) -> (Option<String>, Option<String>) {
153-
154-
// Case 1: first positional is a valid ID prefix
155-
if id_prefix.is_some() {
156-
if let Some(real_id) = detect_id(&id_prefix) {
157-
return (Some(real_id), text_value);
167+
168+
/// Determine if the call looked like:
169+
/// msg <id> --edit new text...
170+
/// OR:
171+
/// msg "some text" --edit
172+
pub fn classify_id_or_text(args: &MsgArgs) -> (Option<String>, Option<String>) {
173+
// Case A: First positional *could* be an ID
174+
if let Some(pfx) = &args.id_prefix {
175+
if let Some(full_id) = detect_id(&Some(pfx.clone())) {
176+
// ID detected
177+
return (Some(full_id), extract_text_from_rest(args));
158178
}
159-
}
160179

161-
// Case 2: first positional is actually text
162-
if let Some(val) = id_prefix {
163-
return (None, Some(val));
180+
// Not an ID → treat as text
181+
return (None, Some(pfx.clone()));
164182
}
165183

166-
// Case 3: only second positional is provided
167-
if let Some(val) = text_value {
168-
return (None, Some(val));
169-
}
184+
// No id_prefix → rely on rest as text
185+
(None, extract_text_from_rest(args))
186+
}
170187

171-
(None, None)
188+
/// Combine trailing args into text
189+
fn extract_text_from_rest(args: &MsgArgs) -> Option<String> {
190+
if args.rest.is_empty() {
191+
None
192+
} else {
193+
Some(args.rest.join(" "))
194+
}
172195
}
173196

197+
//
198+
// ======================================================
199+
// PREFIX UTILITIES
200+
// ======================================================
201+
//
174202

175-
/// Internal helper: check for prefix match safely
176203
fn resolve_prefix_if_exists(pfx: &str) -> Option<String> {
177204
let fur = Path::new(".fur");
178205
let (_index, tid) = resolve_active_conversation();
@@ -181,15 +208,15 @@ fn resolve_prefix_if_exists(pfx: &str) -> Option<String> {
181208
let convo: Value =
182209
serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
183210

184-
let root = convo["messages"]
211+
let root_ids = convo["messages"]
185212
.as_array()
186213
.unwrap_or(&vec![])
187214
.iter()
188215
.filter_map(|x| x.as_str().map(|s| s.to_string()))
189-
.collect::<Vec<String>>();
216+
.collect::<Vec<_>>();
190217

191218
let matches: Vec<&String> =
192-
root.iter().filter(|id| id.starts_with(pfx)).collect();
219+
root_ids.iter().filter(|id| id.starts_with(pfx)).collect();
193220

194221
if matches.len() == 1 {
195222
Some(matches[0].clone())
@@ -200,46 +227,47 @@ fn resolve_prefix_if_exists(pfx: &str) -> Option<String> {
200227

201228
//
202229
// ======================================================
203-
// ID RESOLUTION HELPERS
230+
// ACTIVE CONVERSATION RESOLUTION
204231
// ======================================================
205232
//
206233

207234
fn resolve_active_conversation() -> (Value, String) {
208235
let idx_path = Path::new(".fur/index.json");
209236
let index: Value =
210237
serde_json::from_str(&fs::read_to_string(idx_path).unwrap()).unwrap();
238+
211239
let tid = index["active_thread"].as_str().unwrap_or("").to_string();
240+
212241
(index, tid)
213242
}
214243

215-
fn resolve_target_message(prefix: Option<String>) -> String {
244+
pub fn resolve_target_message(prefix: Option<String>) -> String {
216245
let fur = Path::new(".fur");
217246

218247
let (index, tid) = resolve_active_conversation();
219248
let convo_path = fur.join("threads").join(format!("{}.json", tid));
249+
220250
let convo: Value =
221251
serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
222252

223-
let root = convo["messages"]
253+
let root_ids = convo["messages"]
224254
.as_array()
225-
.unwrap_or(&vec![])
255+
.unwrap()
226256
.iter()
227257
.filter_map(|v| v.as_str().map(|s| s.to_string()))
228-
.collect::<Vec<String>>();
258+
.collect::<Vec<_>>();
229259

230-
if let Some(p) = prefix {
231-
return resolve_prefix(&root, &p);
260+
if let Some(pfx) = prefix {
261+
return resolve_prefix(&root_ids, &pfx);
232262
}
233263

234-
// current_message wins
235264
if let Some(cur) = index["current_message"].as_str() {
236265
if !cur.is_empty() {
237266
return cur.to_string();
238267
}
239268
}
240269

241-
// fallback → last root
242-
root.last().expect("❌ No messages").to_string()
270+
root_ids.last().expect("❌ No messages").to_string()
243271
}
244272

245273
fn resolve_prefix(root_ids: &Vec<String>, prefix: &str) -> String {
@@ -268,19 +296,12 @@ fn recursive_delete(mid: &str) {
268296
let fur = Path::new(".fur");
269297
let msg_path = fur.join("messages").join(format!("{}.json", mid));
270298

271-
let content = match fs::read_to_string(&msg_path) {
272-
Ok(c) => c,
273-
Err(_) => return,
274-
};
275-
276-
let msg: Value = match serde_json::from_str(&content) {
277-
Ok(v) => v,
278-
Err(_) => return,
279-
};
299+
let Ok(content) = fs::read_to_string(&msg_path) else { return };
300+
let Ok(msg) = serde_json::from_str::<Value>(&content) else { return };
280301

281302
if let Some(children) = msg["children"].as_array() {
282-
for c in children {
283-
if let Some(cid) = c.as_str() {
303+
for child in children {
304+
if let Some(cid) = child.as_str() {
284305
recursive_delete(cid);
285306
}
286307
}
@@ -292,11 +313,12 @@ fn recursive_delete(mid: &str) {
292313
fn remove_from_parent_or_root(mid: &str) {
293314
let fur = Path::new(".fur");
294315

316+
// Load deleted msg metadata (if exists)
295317
let msg_path = fur.join("messages").join(format!("{}.json", mid));
296318
let raw = fs::read_to_string(&msg_path).unwrap_or("{}".into());
297319
let msg: Value = serde_json::from_str(&raw).unwrap_or(json!({}));
298320

299-
// If message had a parent
321+
// If part of a thread tree
300322
if let Some(pid) = msg["parent"].as_str() {
301323
let ppath = fur.join("messages").join(format!("{}.json", pid));
302324
if let Ok(content) = fs::read_to_string(&ppath) {
@@ -309,9 +331,10 @@ fn remove_from_parent_or_root(mid: &str) {
309331
return;
310332
}
311333

312-
// Else: it's root-level in conversation
334+
// Otherwise part of root list
313335
let (_index, tid) = resolve_active_conversation();
314336
let convo_path = fur.join("threads").join(format!("{}.json", tid));
337+
315338
let mut convo: Value =
316339
serde_json::from_str(&fs::read_to_string(&convo_path).unwrap()).unwrap();
317340

@@ -325,6 +348,7 @@ fn remove_from_parent_or_root(mid: &str) {
325348
fn update_current_after_delete(mid: &str) {
326349
let fur = Path::new(".fur");
327350
let idx_path = fur.join("index.json");
351+
328352
let mut index: Value =
329353
serde_json::from_str(&fs::read_to_string(&idx_path).unwrap()).unwrap();
330354

0 commit comments

Comments
 (0)