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