返回 CodeWhale
note.rs
根目录 / crates / tui / src / commands / groups / memory / note.rs
1 //! Note command: manage persistent workspace notes.
2
3 use crate::tui::app::App;
4 use std::fs;
5 use std::io::Write;
6 use std::path::{Path, PathBuf};
7
8 use crate::commands::CommandResult;
9
10 const USAGE: &str = "/note <text> | /note add <text> | /note list | /note show <n> | /note edit <n> <text> | /note remove <n> | /note clear | /note path";
11
12 /// Manage the persistent workspace notes file.
13 fn note(app: &mut App, content: Option<&str>) -> CommandResult {
14 let input = match content {
15 Some(c) => c.trim(),
16 None => {
17 return CommandResult::error(format!("Usage: {USAGE}"));
18 }
19 };
20
21 if input.is_empty() {
22 return CommandResult::error("Note content cannot be empty");
23 }
24
25 let notes_path = notes_path(app);
26 let (command, rest) = split_command(input);
27
28 match command.to_ascii_lowercase().as_str() {
29 "add" => append_note_command(&notes_path, rest),
30 "list" => list_notes_command(&notes_path),
31 "show" => show_note_command(&notes_path, rest),
32 "edit" => edit_note_command(&notes_path, rest),
33 "remove" | "rm" | "delete" => remove_note_command(&notes_path, rest),
34 "clear" => clear_notes_command(&notes_path),
35 "path" => CommandResult::message(format!("Notes path: {}", notes_path.display())),
36 "help" => CommandResult::message(format!("Usage: {USAGE}")),
37 _ => append_note_command(&notes_path, Some(input)),
38 }
39 }
40
41 fn notes_path(app: &App) -> PathBuf {
42 let primary = app.workspace.join(".codewhale").join("notes.md");
43 if primary.exists() {
44 return primary;
45 }
46 app.workspace.join(".deepseek").join("notes.md")
47 }
48
49 fn split_command(input: &str) -> (&str, Option<&str>) {
50 match input.find(char::is_whitespace) {
51 Some(index) => (&input[..index], Some(input[index..].trim())),
52 None => (input, None),
53 }
54 }
55
56 fn append_note_command(notes_path: &Path, content: Option<&str>) -> CommandResult {
57 let Some(note_content) = content.map(str::trim).filter(|content| !content.is_empty()) else {
58 return CommandResult::error("Usage: /note add <text>");
59 };
60
61 match append_note(notes_path, note_content) {
62 Ok(()) => CommandResult::message(format!("Note appended to {}", notes_path.display())),
63 Err(e) => CommandResult::error(e),
64 }
65 }
66
67 fn list_notes_command(notes_path: &Path) -> CommandResult {
68 let notes = match read_notes(notes_path) {
69 Ok(notes) => notes,
70 Err(e) => return CommandResult::error(e),
71 };
72
73 if notes.is_empty() {
74 return CommandResult::message(format!("No notes found at {}", notes_path.display()));
75 }
76
77 let mut output = format!("Notes in {}:", notes_path.display());
78 for (index, note) in notes.iter().enumerate() {
79 output.push_str(&format!("\n\n{}. {}", index + 1, note_preview(note)));
80 }
81 CommandResult::message(output)
82 }
83
84 fn show_note_command(notes_path: &Path, rest: Option<&str>) -> CommandResult {
85 let notes = match read_notes(notes_path) {
86 Ok(notes) => notes,
87 Err(e) => return CommandResult::error(e),
88 };
89 let index = match parse_note_index(rest, notes.len(), "/note show <n>") {
90 Ok(index) => index,
91 Err(e) => return CommandResult::error(e),
92 };
93
94 CommandResult::message(format!("Note {}:\n\n{}", index + 1, notes[index]))
95 }
96
97 fn edit_note_command(notes_path: &Path, rest: Option<&str>) -> CommandResult {
98 let Some(rest) = rest else {
99 return CommandResult::error("Usage: /note edit <n> <text>");
100 };
101 let (index_text, new_content) = match split_command(rest) {
102 (index_text, Some(new_content)) if !new_content.trim().is_empty() => {
103 (index_text, new_content.trim())
104 }
105 _ => return CommandResult::error("Usage: /note edit <n> <text>"),
106 };
107
108 let mut notes = match read_notes(notes_path) {
109 Ok(notes) => notes,
110 Err(e) => return CommandResult::error(e),
111 };
112 let index = match parse_note_index(Some(index_text), notes.len(), "/note edit <n> <text>") {
113 Ok(index) => index,
114 Err(e) => return CommandResult::error(e),
115 };
116
117 notes[index] = new_content.to_string();
118 match write_notes(notes_path, &notes) {
119 Ok(()) => CommandResult::message(format!(
120 "Note {} updated in {}",
121 index + 1,
122 notes_path.display()
123 )),
124 Err(e) => CommandResult::error(e),
125 }
126 }
127
128 fn remove_note_command(notes_path: &Path, rest: Option<&str>) -> CommandResult {
129 let mut notes = match read_notes(notes_path) {
130 Ok(notes) => notes,
131 Err(e) => return CommandResult::error(e),
132 };
133 let index = match parse_note_index(rest, notes.len(), "/note remove <n>") {
134 Ok(index) => index,
135 Err(e) => return CommandResult::error(e),
136 };
137
138 notes.remove(index);
139 match write_notes(notes_path, &notes) {
140 Ok(()) => CommandResult::message(format!(
141 "Note {} removed from {}",
142 index + 1,
143 notes_path.display()
144 )),
145 Err(e) => CommandResult::error(e),
146 }
147 }
148
149 fn clear_notes_command(notes_path: &Path) -> CommandResult {
150 match write_notes(notes_path, &[]) {
151 Ok(()) => CommandResult::message(format!("Notes cleared in {}", notes_path.display())),
152 Err(e) => CommandResult::error(e),
153 }
154 }
155
156 fn append_note(notes_path: &Path, note_content: &str) -> Result<(), String> {
157 ensure_notes_parent(notes_path)?;
158
159 let mut file = match fs::OpenOptions::new()
160 .create(true)
161 .append(true)
162 .open(notes_path)
163 {
164 Ok(f) => f,
165 Err(e) => {
166 return Err(format!("Failed to open notes file: {e}"));
167 }
168 };
169
170 // Write separator and note content
171 if let Err(e) = writeln!(file, "\n---\n{note_content}") {
172 return Err(format!("Failed to write note: {e}"));
173 }
174
175 Ok(())
176 }
177
178 fn read_notes(notes_path: &Path) -> Result<Vec<String>, String> {
179 match fs::read_to_string(notes_path) {
180 Ok(content) => Ok(parse_notes(&content)),
181 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
182 Err(e) => Err(format!("Failed to read notes file: {e}")),
183 }
184 }
185
186 fn write_notes(notes_path: &Path, notes: &[String]) -> Result<(), String> {
187 ensure_notes_parent(notes_path)?;
188 let content = notes
189 .iter()
190 .map(|note| format!("---\n{}", note.trim()))
191 .collect::<Vec<_>>()
192 .join("\n\n");
193 fs::write(notes_path, content).map_err(|e| format!("Failed to write notes file: {e}"))
194 }
195
196 fn ensure_notes_parent(notes_path: &Path) -> Result<(), String> {
197 if let Some(parent) = notes_path.parent() {
198 fs::create_dir_all(parent).map_err(|e| format!("Failed to create notes directory: {e}"))?;
199 }
200 Ok(())
201 }
202
203 fn parse_notes(content: &str) -> Vec<String> {
204 let mut notes = Vec::new();
205 let mut current = Vec::new();
206 let mut saw_separator = false;
207
208 for line in content.lines() {
209 if line.trim() == "---" {
210 if saw_separator || !current.is_empty() {
211 push_note(&mut notes, &current);
212 current.clear();
213 }
214 saw_separator = true;
215 } else if saw_separator || !line.trim().is_empty() {
216 current.push(line);
217 }
218 }
219
220 if saw_separator {
221 push_note(&mut notes, &current);
222 } else {
223 let trimmed = content.trim();
224 if !trimmed.is_empty() {
225 notes.push(trimmed.to_string());
226 }
227 }
228
229 notes
230 }
231
232 fn push_note(notes: &mut Vec<String>, lines: &[&str]) {
233 let note = lines.join("\n").trim().to_string();
234 if !note.is_empty() {
235 notes.push(note);
236 }
237 }
238
239 fn note_preview(note: &str) -> String {
240 let first_line = note
241 .lines()
242 .find_map(|line| {
243 let trimmed = line.trim();
244 (!trimmed.is_empty()).then_some(trimmed)
245 })
246 .unwrap_or("(empty note)");
247 if note.lines().filter(|line| !line.trim().is_empty()).count() > 1 {
248 format!("{first_line} ...")
249 } else {
250 first_line.to_string()
251 }
252 }
253
254 fn parse_note_index(rest: Option<&str>, note_count: usize, usage: &str) -> Result<usize, String> {
255 let Some(index_text) = rest.map(str::trim).filter(|text| !text.is_empty()) else {
256 return Err(format!("Usage: {usage}"));
257 };
258 let index = index_text
259 .parse::<usize>()
260 .map_err(|_| format!("Invalid note number: {index_text}"))?;
261 if index == 0 || index > note_count {
262 return Err(format!(
263 "Note number {index} out of range; there are {note_count} note(s)"
264 ));
265 }
266 Ok(index - 1)
267 }
268
269 pub(in crate::commands) const COMMAND_INFO: crate::commands::traits::CommandInfo =
270 crate::commands::traits::CommandInfo {
271 name: "note",
272 aliases: &[],
273 usage: "/note [add|list|show|edit|remove|clear|path]",
274 description_id: crate::localization::MessageId::CmdNoteDescription,
275 };
276
277 pub(in crate::commands) struct NoteCmd;
278
279 impl crate::commands::traits::RegisterCommand for NoteCmd {
280 fn info() -> &'static crate::commands::traits::CommandInfo {
281 &COMMAND_INFO
282 }
283
284 fn execute(
285 app: &mut crate::tui::app::App,
286 arg: Option<&str>,
287 ) -> crate::commands::CommandResult {
288 note(app, arg)
289 }
290 }
291
292 #[cfg(test)]
293 mod tests {
294 use super::*;
295 use crate::config::Config;
296 use crate::tui::app::{App, TuiOptions};
297 use std::path::PathBuf;
298 use tempfile::TempDir;
299
300 fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
301 let options = TuiOptions {
302 skills_dir: tmpdir.path().join("skills"),
303 memory_path: tmpdir.path().join("memory.md"),
304 notes_path: tmpdir.path().join("notes.txt"),
305 mcp_config_path: tmpdir.path().join("mcp.json"),
306 ..crate::test_support::test_tui_options(tmpdir.path())
307 };
308 App::new(options, &Config::default())
309 }
310
311 fn notes_path(tmpdir: &TempDir) -> PathBuf {
312 tmpdir.path().join(".deepseek").join("notes.md")
313 }
314
315 fn message(result: CommandResult) -> String {
316 result.message.expect("command message")
317 }
318
319 #[test]
320 fn test_note_without_content_returns_error() {
321 let tmpdir = TempDir::new().unwrap();
322 let mut app = create_test_app_with_tmpdir(&tmpdir);
323 let result = note(&mut app, None);
324 assert!(result.message.is_some());
325 assert!(result.message.unwrap().contains("Usage: /note"));
326 }
327
328 #[test]
329 fn test_note_with_empty_content_returns_error() {
330 let tmpdir = TempDir::new().unwrap();
331 let mut app = create_test_app_with_tmpdir(&tmpdir);
332 let result = note(&mut app, Some(" "));
333 assert!(result.message.is_some());
334 assert!(result.message.unwrap().contains("cannot be empty"));
335 }
336
337 #[test]
338 fn test_note_appends_to_file() {
339 let tmpdir = TempDir::new().unwrap();
340 let mut app = create_test_app_with_tmpdir(&tmpdir);
341 let result = note(&mut app, Some("Test note content"));
342 assert!(result.message.is_some());
343 let msg = message(result);
344 assert!(msg.contains("Note appended to"));
345
346 let notes_path = notes_path(&tmpdir);
347 assert!(notes_path.exists());
348 let content = std::fs::read_to_string(&notes_path).unwrap();
349 assert!(content.contains("Test note content"));
350 }
351
352 #[test]
353 fn test_note_multiple_appends() {
354 let tmpdir = TempDir::new().unwrap();
355 let mut app = create_test_app_with_tmpdir(&tmpdir);
356 note(&mut app, Some("First note"));
357 note(&mut app, Some("Second note"));
358
359 let notes_path = notes_path(&tmpdir);
360 let content = std::fs::read_to_string(&notes_path).unwrap();
361 assert!(content.contains("First note"));
362 assert!(content.contains("Second note"));
363 // Should have two separators
364 assert_eq!(content.matches("---").count(), 2);
365 }
366
367 #[test]
368 fn test_note_list_numbers_entries_without_storing_numbers() {
369 let tmpdir = TempDir::new().unwrap();
370 let mut app = create_test_app_with_tmpdir(&tmpdir);
371 note(&mut app, Some("Alpha note"));
372 note(&mut app, Some("Beta note"));
373
374 let listed = message(note(&mut app, Some("list")));
375 assert!(listed.contains("1. Alpha note"));
376 assert!(listed.contains("2. Beta note"));
377
378 let content = std::fs::read_to_string(notes_path(&tmpdir)).unwrap();
379 assert!(content.contains("Alpha note"));
380 assert!(!content.contains("1. Alpha note"));
381 }
382
383 #[test]
384 fn test_note_show_displays_full_multiline_note() {
385 let tmpdir = TempDir::new().unwrap();
386 let mut app = create_test_app_with_tmpdir(&tmpdir);
387 note(&mut app, Some("add first line\nsecond line"));
388
389 let shown = message(note(&mut app, Some("show 1")));
390 assert!(shown.contains("Note 1:"));
391 assert!(shown.contains("first line\nsecond line"));
392 }
393
394 #[test]
395 fn test_note_edit_updates_numbered_entry() {
396 let tmpdir = TempDir::new().unwrap();
397 let mut app = create_test_app_with_tmpdir(&tmpdir);
398 note(&mut app, Some("First note"));
399 note(&mut app, Some("Second note"));
400
401 let edited = message(note(&mut app, Some("edit 2 Updated second note")));
402 assert!(edited.contains("Note 2 updated"));
403
404 let content = std::fs::read_to_string(notes_path(&tmpdir)).unwrap();
405 assert!(content.contains("First note"));
406 assert!(content.contains("Updated second note"));
407 assert!(!content.contains("Second note"));
408 }
409
410 #[test]
411 fn test_note_remove_renumbers_remaining_entries() {
412 let tmpdir = TempDir::new().unwrap();
413 let mut app = create_test_app_with_tmpdir(&tmpdir);
414 note(&mut app, Some("First note"));
415 note(&mut app, Some("Second note"));
416 note(&mut app, Some("Third note"));
417
418 let removed = message(note(&mut app, Some("remove 2")));
419 assert!(removed.contains("Note 2 removed"));
420
421 let listed = message(note(&mut app, Some("list")));
422 assert!(listed.contains("1. First note"));
423 assert!(listed.contains("2. Third note"));
424 assert!(!listed.contains("Second note"));
425 }
426
427 #[test]
428 fn test_note_clear_empties_file() {
429 let tmpdir = TempDir::new().unwrap();
430 let mut app = create_test_app_with_tmpdir(&tmpdir);
431 note(&mut app, Some("First note"));
432
433 let cleared = message(note(&mut app, Some("clear")));
434 assert!(cleared.contains("Notes cleared"));
435 assert_eq!(std::fs::read_to_string(notes_path(&tmpdir)).unwrap(), "");
436 }
437
438 #[test]
439 fn test_note_path_prints_workspace_notes_file() {
440 let tmpdir = TempDir::new().unwrap();
441 let mut app = create_test_app_with_tmpdir(&tmpdir);
442
443 let path = message(note(&mut app, Some("path")));
444 assert!(path.contains(".deepseek"));
445 assert!(path.contains("notes.md"));
446 }
447
448 #[test]
449 fn test_note_rejects_out_of_range_index() {
450 let tmpdir = TempDir::new().unwrap();
451 let mut app = create_test_app_with_tmpdir(&tmpdir);
452 note(&mut app, Some("Only note"));
453
454 let result = note(&mut app, Some("show 2"));
455 assert!(result.message.unwrap().contains("out of range"));
456 }
457
458 #[test]
459 fn test_parse_notes_handles_plain_text_before_separator() {
460 let parsed = parse_notes("plain note\n---\nseparated note");
461 assert_eq!(parsed, vec!["plain note", "separated note"]);
462 }
463 }
464
464 lines RUST