返回 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 let command = command.to_ascii_lowercase();
30 if !matches!(command.as_str(), "path" | "help")
31 && let Err(error) = ensure_notes_target_in_workspace(&notes_path, workspace)
32 {
33 return CommandResult::error(error);
34 }
35
36 match command.as_str() {
37 "add" => append_note_command(workspace, &notes_path, rest),
38 "list" => list_notes_command(workspace, &notes_path),
39 "show" => show_note_command(workspace, &notes_path, rest),
40 "edit" => edit_note_command(workspace, &notes_path, rest),
41 "remove" | "rm" | "delete" => remove_note_command(workspace, &notes_path, rest),
42 "clear" => clear_notes_command(workspace, &notes_path),
43 "path" => CommandResult::message(format!("Notes path: {}", notes_path.display())),
44 "help" => CommandResult::message(format!("Usage: {USAGE}")),
45 _ => append_note_command(workspace, &notes_path, Some(input)),
46 }
47 }
48
49 /// Resolve the notes file. An existing `.codewhale` notes file is preferred;
50 /// otherwise the `.deepseek` notes path is used (D3 — the fallback stays
51 /// handler-owned through standard filesystem operations). The dock's NOTES
52 /// view reads the same file through this resolver (#6565).
53 pub(crate) fn notes_path(workspace: &Path) -> PathBuf {
54 let primary = workspace.join(".codewhale").join("notes.md");
55 if primary.symlink_metadata().is_ok() || workspace.join(".codewhale").is_symlink() {
56 return primary;
57 }
58 workspace.join(".deepseek").join("notes.md")
59 }
60
61 /// The notes file lives in the workspace, which may be a cloned repository:
62 /// a committed `notes.md -> ~/.zshrc` or a symlinked `.codewhale/` must not
63 /// let `/note clear` empty (or `/note list` print) a file outside it. Same
64 /// rule as the `note` tool.
65 fn ensure_notes_target_in_workspace(notes_path: &Path, workspace: &Path) -> Result<(), String> {
66 if let Ok(meta) = fs::symlink_metadata(notes_path)
67 && (meta.file_type().is_symlink() || !meta.is_file())
68 {
69 return Err(format!(
70 "Refusing to use {}: the notes path is a symlink or not a regular file.",
71 notes_path.display()
72 ));
73 }
74 let (Some(parent), Ok(root)) = (notes_path.parent(), fs::canonicalize(workspace)) else {
75 return Ok(());
76 };
77 // Only existing ancestors can redirect; the rest is created as real
78 // directories.
79 let mut existing = parent.to_path_buf();
80 while !existing.exists() {
81 if !existing.pop() {
82 return Ok(());
83 }
84 }
85 match fs::canonicalize(&existing) {
86 Ok(resolved) if !resolved.starts_with(&root) => Err(format!(
87 "Refusing to use {}: its directory resolves outside the workspace.",
88 notes_path.display()
89 )),
90 _ => Ok(()),
91 }
92 }
93
94 fn split_command(input: &str) -> (&str, Option<&str>) {
95 match input.find(char::is_whitespace) {
96 Some(index) => (&input[..index], Some(input[index..].trim())),
97 None => (input, None),
98 }
99 }
100
101 fn append_note_command(
102 workspace: &Path,
103 notes_path: &Path,
104 content: Option<&str>,
105 ) -> CommandResult {
106 let Some(note_content) = content.map(str::trim).filter(|content| !content.is_empty()) else {
107 return CommandResult::error("Usage: /note add <text>");
108 };
109
110 match append_note(workspace, notes_path, note_content) {
111 Ok(()) => CommandResult::message(format!("Note appended to {}", notes_path.display())),
112 Err(e) => CommandResult::error(e),
113 }
114 }
115
116 fn list_notes_command(workspace: &Path, notes_path: &Path) -> CommandResult {
117 let notes = match read_notes(workspace, notes_path) {
118 Ok(notes) => notes,
119 Err(e) => return CommandResult::error(e),
120 };
121
122 if notes.is_empty() {
123 return CommandResult::message(format!("No notes found at {}", notes_path.display()));
124 }
125
126 let mut output = format!("Notes in {}:", notes_path.display());
127 for (index, note) in notes.iter().enumerate() {
128 output.push_str(&format!("\n\n{}. {}", index + 1, note_preview(note)));
129 }
130 CommandResult::message(output)
131 }
132
133 fn show_note_command(workspace: &Path, notes_path: &Path, rest: Option<&str>) -> CommandResult {
134 let notes = match read_notes(workspace, 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 show <n>") {
139 Ok(index) => index,
140 Err(e) => return CommandResult::error(e),
141 };
142
143 CommandResult::message(format!("Note {}:\n\n{}", index + 1, notes[index]))
144 }
145
146 fn edit_note_command(workspace: &Path, notes_path: &Path, rest: Option<&str>) -> CommandResult {
147 let Some(rest) = rest else {
148 return CommandResult::error("Usage: /note edit <n> <text>");
149 };
150 let (index_text, new_content) = match split_command(rest) {
151 (index_text, Some(new_content)) if !new_content.trim().is_empty() => {
152 (index_text, new_content.trim())
153 }
154 _ => return CommandResult::error("Usage: /note edit <n> <text>"),
155 };
156
157 let mut notes = match read_notes(workspace, notes_path) {
158 Ok(notes) => notes,
159 Err(e) => return CommandResult::error(e),
160 };
161 let index = match parse_note_index(Some(index_text), notes.len(), "/note edit <n> <text>") {
162 Ok(index) => index,
163 Err(e) => return CommandResult::error(e),
164 };
165
166 notes[index] = new_content.to_string();
167 match write_notes(workspace, notes_path, &notes) {
168 Ok(()) => CommandResult::message(format!(
169 "Note {} updated in {}",
170 index + 1,
171 notes_path.display()
172 )),
173 Err(e) => CommandResult::error(e),
174 }
175 }
176
177 fn remove_note_command(workspace: &Path, notes_path: &Path, rest: Option<&str>) -> CommandResult {
178 let mut notes = match read_notes(workspace, notes_path) {
179 Ok(notes) => notes,
180 Err(e) => return CommandResult::error(e),
181 };
182 let index = match parse_note_index(rest, notes.len(), "/note remove <n>") {
183 Ok(index) => index,
184 Err(e) => return CommandResult::error(e),
185 };
186
187 notes.remove(index);
188 match write_notes(workspace, notes_path, &notes) {
189 Ok(()) => CommandResult::message(format!(
190 "Note {} removed from {}",
191 index + 1,
192 notes_path.display()
193 )),
194 Err(e) => CommandResult::error(e),
195 }
196 }
197
198 fn clear_notes_command(workspace: &Path, notes_path: &Path) -> CommandResult {
199 match write_notes(workspace, notes_path, &[]) {
200 Ok(()) => CommandResult::message(format!("Notes cleared in {}", notes_path.display())),
201 Err(e) => CommandResult::error(e),
202 }
203 }
204
205 fn append_note(workspace: &Path, notes_path: &Path, note_content: &str) -> Result<(), String> {
206 let mut file = match crate::fs_confined::open_append(workspace, notes_path) {
207 Ok(f) => f,
208 Err(e) => {
209 return Err(format!("Failed to open notes file: {e}"));
210 }
211 };
212
213 // Write separator and note content
214 if let Err(e) = writeln!(file, "\n---\n{note_content}") {
215 return Err(format!("Failed to write note: {e}"));
216 }
217
218 Ok(())
219 }
220
221 pub(crate) fn read_notes(workspace: &Path, notes_path: &Path) -> Result<Vec<String>, String> {
222 match crate::fs_confined::read_to_string(workspace, notes_path) {
223 Ok(content) => Ok(parse_notes(&content)),
224 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
225 Err(e) => Err(format!("Failed to read notes file: {e}")),
226 }
227 }
228
229 fn write_notes(workspace: &Path, notes_path: &Path, notes: &[String]) -> Result<(), String> {
230 let content = notes
231 .iter()
232 .map(|note| format!("---\n{}", note.trim()))
233 .collect::<Vec<_>>()
234 .join("\n\n");
235 crate::fs_confined::write(workspace, notes_path, content.as_bytes())
236 .map_err(|e| format!("Failed to write notes file: {e}"))
237 }
238
239 fn parse_notes(content: &str) -> Vec<String> {
240 let mut notes = Vec::new();
241 let mut current = Vec::new();
242 let mut saw_separator = false;
243
244 for line in content.lines() {
245 if line.trim() == "---" {
246 if saw_separator || !current.is_empty() {
247 push_note(&mut notes, &current);
248 current.clear();
249 }
250 saw_separator = true;
251 } else if saw_separator || !line.trim().is_empty() {
252 current.push(line);
253 }
254 }
255
256 if saw_separator {
257 push_note(&mut notes, &current);
258 } else {
259 let trimmed = content.trim();
260 if !trimmed.is_empty() {
261 notes.push(trimmed.to_string());
262 }
263 }
264
265 notes
266 }
267
268 fn push_note(notes: &mut Vec<String>, lines: &[&str]) {
269 let note = lines.join("\n").trim().to_string();
270 if !note.is_empty() {
271 notes.push(note);
272 }
273 }
274
275 fn note_preview(note: &str) -> String {
276 let first_line = note
277 .lines()
278 .find_map(|line| {
279 let trimmed = line.trim();
280 (!trimmed.is_empty()).then_some(trimmed)
281 })
282 .unwrap_or("(empty note)");
283 if note.lines().filter(|line| !line.trim().is_empty()).count() > 1 {
284 format!("{first_line} ...")
285 } else {
286 first_line.to_string()
287 }
288 }
289
290 fn parse_note_index(rest: Option<&str>, note_count: usize, usage: &str) -> Result<usize, String> {
291 let Some(index_text) = rest.map(str::trim).filter(|text| !text.is_empty()) else {
292 return Err(format!("Usage: {usage}"));
293 };
294 let index = index_text
295 .parse::<usize>()
296 .map_err(|_| format!("Invalid note number: {index_text}"))?;
297 if index == 0 || index > note_count {
298 return Err(format!(
299 "Note number {index} out of range; there are {note_count} note(s)"
300 ));
301 }
302 Ok(index - 1)
303 }
304
305 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
306 name: "note",
307 aliases: &[],
308 usage: "/note [add|list|show|edit|remove|clear|path]",
309 description_key: "cmd_note_description",
310 };
311
312 pub(in crate::commands) struct NoteCmd;
313
314 impl RegisterCommand<CommandResult> for NoteCmd {
315 fn info() -> &'static CommandInfo {
316 &COMMAND_INFO
317 }
318
319 fn handler() -> CommandHandler<CommandResult> {
320 CommandHandler::Contextual {
321 capabilities: CommandCapabilities::WORKSPACE,
322 handler: note_contextual,
323 }
324 }
325 }
326
327 fn note_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
328 let parts = contexts.into_parts();
329 let Some(workspace) = parts.workspace.as_deref() else {
330 return CommandResult::error("Command capability unavailable: workspace");
331 };
332 note(&workspace.workspace(), arg)
333 }
334
335 #[cfg(test)]
336 mod tests {
337 use super::*;
338 use std::path::PathBuf;
339 use tempfile::TempDir;
340
341 use codewhale_command_contract::facets::CommandWorkspaceContext;
342
343 struct FakeWorkspace {
344 path: PathBuf,
345 }
346
347 impl CommandWorkspaceContext for FakeWorkspace {
348 fn workspace(&self) -> PathBuf {
349 self.path.clone()
350 }
351
352 fn work_state_snapshot(&self) -> Result<Option<String>, String> {
353 Ok(None)
354 }
355
356 fn operation_digest(&mut self) -> Result<String, String> {
357 Ok("No active operations or to-do items.".to_string())
358 }
359 }
360
361 fn fake_workspace(tmpdir: &TempDir) -> FakeWorkspace {
362 FakeWorkspace {
363 path: tmpdir.path().to_path_buf(),
364 }
365 }
366
367 fn notes_path(tmpdir: &TempDir) -> PathBuf {
368 tmpdir.path().join(".deepseek").join("notes.md")
369 }
370
371 fn message(result: CommandResult) -> String {
372 result.message.expect("command message")
373 }
374
375 #[cfg(unix)]
376 #[test]
377 fn confined_notes_refuse_linked_files_and_directories() {
378 use std::os::unix::fs::symlink;
379
380 for directory in [".codewhale", ".deepseek"] {
381 for linked_directory in [false, true] {
382 let workspace = TempDir::new().unwrap();
383 let outside = TempDir::new().unwrap();
384 let original = "---\nfirst note\n\n---\nsecond note";
385 let target = outside.path().join("notes.md");
386 std::fs::write(&target, original).unwrap();
387 let parent = workspace.path().join(directory);
388 if linked_directory {
389 symlink(outside.path(), &parent).unwrap();
390 } else {
391 std::fs::create_dir(&parent).unwrap();
392 symlink(&target, parent.join("notes.md")).unwrap();
393 }
394 for command in [
395 "list",
396 "show 1",
397 "add next",
398 "edit 1 next",
399 "remove 1",
400 "clear",
401 ] {
402 let result = note(workspace.path(), Some(command));
403 assert!(
404 result.is_error,
405 "{directory}, {linked_directory}, {command}"
406 );
407 assert_eq!(std::fs::read_to_string(&target).unwrap(), original);
408 }
409 }
410 }
411 }
412
413 #[cfg(unix)]
414 #[test]
415 fn confined_notes_refuse_dangling_links() {
416 for directory in [".codewhale", ".deepseek"] {
417 let workspace = TempDir::new().unwrap();
418 let outside = TempDir::new().unwrap();
419 let target = outside.path().join("missing.md");
420 let parent = workspace.path().join(directory);
421 std::fs::create_dir(&parent).unwrap();
422 std::os::unix::fs::symlink(&target, parent.join("notes.md")).unwrap();
423 for command in ["list", "add next", "clear"] {
424 assert!(note(workspace.path(), Some(command)).is_error);
425 assert!(!target.exists());
426 }
427 if directory == ".codewhale" {
428 assert!(!workspace.path().join(".deepseek").exists());
429 }
430 }
431 }
432
433 #[test]
434 fn test_note_without_content_returns_error() {
435 let tmpdir = TempDir::new().unwrap();
436 let workspace = fake_workspace(&tmpdir);
437 let result = note(&workspace.path, None);
438 assert!(result.message.is_some());
439 assert!(result.message.unwrap().contains("Usage: /note"));
440 }
441
442 #[test]
443 fn test_note_with_empty_content_returns_error() {
444 let tmpdir = TempDir::new().unwrap();
445 let workspace = fake_workspace(&tmpdir);
446 let result = note(&workspace.path, Some(" "));
447 assert!(result.message.is_some());
448 assert!(result.message.unwrap().contains("cannot be empty"));
449 }
450
451 #[test]
452 fn test_note_appends_to_file() {
453 let tmpdir = TempDir::new().unwrap();
454 let workspace = fake_workspace(&tmpdir);
455 let result = note(&workspace.path, Some("Test note content"));
456 assert!(result.message.is_some());
457 let msg = message(result);
458 assert!(msg.contains("Note appended to"));
459
460 let notes_path = notes_path(&tmpdir);
461 assert!(notes_path.exists());
462 let content = std::fs::read_to_string(&notes_path).unwrap();
463 assert!(content.contains("Test note content"));
464 }
465
466 #[test]
467 fn test_note_multiple_appends() {
468 let tmpdir = TempDir::new().unwrap();
469 let workspace = fake_workspace(&tmpdir);
470 note(&workspace.path, Some("First note"));
471 note(&workspace.path, Some("Second note"));
472
473 let notes_path = notes_path(&tmpdir);
474 let content = std::fs::read_to_string(&notes_path).unwrap();
475 assert!(content.contains("First note"));
476 assert!(content.contains("Second note"));
477 // Should have two separators
478 assert_eq!(content.matches("---").count(), 2);
479 }
480
481 #[test]
482 fn test_note_list_numbers_entries_without_storing_numbers() {
483 let tmpdir = TempDir::new().unwrap();
484 let workspace = fake_workspace(&tmpdir);
485 note(&workspace.path, Some("Alpha note"));
486 note(&workspace.path, Some("Beta note"));
487
488 let listed = message(note(&workspace.path, Some("list")));
489 assert!(listed.contains("1. Alpha note"));
490 assert!(listed.contains("2. Beta note"));
491
492 let content = std::fs::read_to_string(notes_path(&tmpdir)).unwrap();
493 assert!(content.contains("Alpha note"));
494 assert!(!content.contains("1. Alpha note"));
495 }
496
497 #[test]
498 fn test_note_show_displays_full_multiline_note() {
499 let tmpdir = TempDir::new().unwrap();
500 let workspace = fake_workspace(&tmpdir);
501 note(&workspace.path, Some("add first line\nsecond line"));
502
503 let shown = message(note(&workspace.path, Some("show 1")));
504 assert!(shown.contains("Note 1:"));
505 assert!(shown.contains("first line\nsecond line"));
506 }
507
508 #[test]
509 fn test_note_edit_updates_numbered_entry() {
510 let tmpdir = TempDir::new().unwrap();
511 let workspace = fake_workspace(&tmpdir);
512 note(&workspace.path, Some("First note"));
513 note(&workspace.path, Some("Second note"));
514
515 let edited = message(note(&workspace.path, Some("edit 2 Updated second note")));
516 assert!(edited.contains("Note 2 updated"));
517
518 let content = std::fs::read_to_string(notes_path(&tmpdir)).unwrap();
519 assert!(content.contains("First note"));
520 assert!(content.contains("Updated second note"));
521 assert!(!content.contains("Second note"));
522 }
523
524 #[test]
525 fn test_note_remove_renumbers_remaining_entries() {
526 let tmpdir = TempDir::new().unwrap();
527 let workspace = fake_workspace(&tmpdir);
528 note(&workspace.path, Some("First note"));
529 note(&workspace.path, Some("Second note"));
530 note(&workspace.path, Some("Third note"));
531
532 let removed = message(note(&workspace.path, Some("remove 2")));
533 assert!(removed.contains("Note 2 removed"));
534
535 let listed = message(note(&workspace.path, Some("list")));
536 assert!(listed.contains("1. First note"));
537 assert!(listed.contains("2. Third note"));
538 assert!(!listed.contains("Second note"));
539 }
540
541 #[cfg(unix)]
542 #[test]
543 fn note_commands_refuse_notes_that_leave_the_workspace() {
544 let tmpdir = TempDir::new().unwrap();
545 let outside = TempDir::new().unwrap();
546 let rc = outside.path().join("zshrc");
547 std::fs::write(&rc, "export PATH=keep\n").unwrap();
548 std::fs::create_dir_all(tmpdir.path().join(".deepseek")).unwrap();
549 std::os::unix::fs::symlink(&rc, notes_path(&tmpdir)).unwrap();
550 for command in ["clear", "remove 1", "edit 1 x", "list", "hello"] {
551 let result = note(tmpdir.path(), Some(command));
552 assert!(result.is_error, "/note {command} must be refused");
553 }
554 assert_eq!(std::fs::read_to_string(&rc).unwrap(), "export PATH=keep\n");
555
556 let linked = TempDir::new().unwrap();
557 std::os::unix::fs::symlink(outside.path(), linked.path().join(".deepseek")).unwrap();
558 assert!(note(linked.path(), Some("clear")).is_error);
559 assert!(!outside.path().join("notes.md").exists());
560 }
561
562 #[test]
563 fn test_note_clear_empties_file() {
564 let tmpdir = TempDir::new().unwrap();
565 let workspace = fake_workspace(&tmpdir);
566 note(&workspace.path, Some("First note"));
567
568 let cleared = message(note(&workspace.path, Some("clear")));
569 assert!(cleared.contains("Notes cleared"));
570 assert_eq!(std::fs::read_to_string(notes_path(&tmpdir)).unwrap(), "");
571 }
572
573 #[test]
574 fn test_note_path_prints_workspace_notes_file() {
575 let tmpdir = TempDir::new().unwrap();
576 let workspace = fake_workspace(&tmpdir);
577
578 let path = message(note(&workspace.path, Some("path")));
579 assert!(path.contains(".deepseek"));
580 assert!(path.contains("notes.md"));
581 }
582
583 #[test]
584 fn test_note_prefers_existing_codewhale_notes_file() {
585 let tmpdir = TempDir::new().unwrap();
586 let codewhale_dir = tmpdir.path().join(".codewhale");
587 std::fs::create_dir_all(&codewhale_dir).unwrap();
588 let codewhale_notes = codewhale_dir.join("notes.md");
589 std::fs::write(&codewhale_notes, "---\nexisting codewhale note").unwrap();
590
591 let workspace = fake_workspace(&tmpdir);
592 let path = message(note(&workspace.path, Some("path")));
593 assert!(path.contains(".codewhale"));
594 assert!(path.contains("notes.md"));
595 assert!(!path.contains(".deepseek"));
596 }
597
598 #[test]
599 fn test_note_rejects_out_of_range_index() {
600 let tmpdir = TempDir::new().unwrap();
601 let workspace = fake_workspace(&tmpdir);
602 note(&workspace.path, Some("Only note"));
603
604 let result = note(&workspace.path, Some("show 2"));
605 assert!(result.message.unwrap().contains("out of range"));
606 }
607
608 #[test]
609 fn test_parse_notes_handles_plain_text_before_separator() {
610 let parsed = parse_notes("plain note\n---\nseparated note");
611 assert_eq!(parsed, vec!["plain note", "separated note"]);
612 }
613
614 #[test]
615 fn note_registration_declares_exactly_workspace() {
616 let CommandHandler::Contextual {
617 capabilities,
618 handler,
619 } = NoteCmd::handler()
620 else {
621 panic!("note must be contextual");
622 };
623 assert_eq!(capabilities, CommandCapabilities::WORKSPACE);
624 assert!(!capabilities.contains(CommandCapabilities::MEMORY));
625 assert!(!capabilities.contains(CommandCapabilities::PRESENTATION));
626 assert!(!capabilities.contains(CommandCapabilities::MEDIA));
627
628 // Missing WORKSPACE fails safely instead of panicking.
629 let missing = handler(CommandContexts::empty(), Some("list"));
630 assert!(missing.is_error);
631 assert_eq!(
632 missing.message.as_deref(),
633 Some("Error: Command capability unavailable: workspace")
634 );
635 assert_eq!(NoteCmd::info().description_key, "cmd_note_description");
636 assert_eq!(NoteCmd::info().name, "note");
637 assert_eq!(NoteCmd::info().aliases, &[] as &[&str]);
638 }
639 }
640
640 lines RUST