返回 CodeWhale
anchor.rs
根目录 / crates / tui / src / commands / groups / core / anchor.rs
1 //! Anchor command: keep critical facts across compaction.
2 //!
3 //! Unlike `/note` (active lookup), anchors are passive. They are automatically
4 //! re-injected into context after compaction in trusted workspaces. Use anchors to
5 //! preserve invariants like "This API's status field is unreliable" or
6 //! ".ssh/ must never be touched".
7
8 use std::io::Write;
9
10 use crate::commands::traits::{CommandInfo, RegisterCommand};
11 use crate::tui::app::App;
12 use codewhale_localization::MessageId;
13
14 use super::CommandResult;
15
16 const USAGE: &str = "/anchor <text> | /anchor list | /anchor remove <n>";
17
18 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
19 name: "anchor",
20 aliases: &["maodian"],
21 usage: USAGE,
22 description_id: MessageId::CmdAnchorDescription,
23 };
24
25 pub(in crate::commands) struct AnchorCmd;
26
27 impl RegisterCommand for AnchorCmd {
28 fn info() -> &'static CommandInfo {
29 &COMMAND_INFO
30 }
31
32 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
33 anchor(app, arg)
34 }
35 }
36
37 /// Handle the `/anchor` command with subcommands:
38 /// - `/anchor <text>` — add a new anchor
39 /// - `/anchor list` — list all anchors
40 /// - `/anchor remove <n>` — remove anchor by 1-based index
41 pub fn anchor(app: &mut App, content: Option<&str>) -> CommandResult {
42 let input = match content {
43 Some(c) => c.trim(),
44 None => {
45 return CommandResult::error(format!("Usage: {USAGE}"));
46 }
47 };
48
49 if input.is_empty() {
50 return CommandResult::error(format!("Usage: {USAGE}"));
51 }
52
53 // Parse subcommands.
54 if input.eq_ignore_ascii_case("list") {
55 return list_anchors(app);
56 }
57
58 if let Some(rest) = input
59 .strip_prefix("remove ")
60 .or_else(|| input.strip_prefix("rm "))
61 .or_else(|| input.strip_prefix("delete "))
62 {
63 return remove_anchor(app, rest.trim());
64 }
65
66 // Default: add a new anchor.
67 add_anchor(app, input)
68 }
69
70 fn anchors_path(app: &App) -> std::path::PathBuf {
71 let primary = app.workspace.join(".codewhale").join("anchors.md");
72 if primary.symlink_metadata().is_ok() || app.workspace.join(".codewhale").is_symlink() {
73 return primary;
74 }
75 app.workspace.join(".deepseek").join("anchors.md")
76 }
77
78 /// Read and split anchors from the file. Each anchor is separated by "\n---\n".
79 fn read_anchors(app: &App) -> Result<Vec<String>, String> {
80 let path = anchors_path(app);
81 let content = match crate::fs_confined::read_to_string(&app.workspace, &path) {
82 Ok(c) => c,
83 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
84 Err(e) => return Err(format!("Failed to read anchors file: {e}")),
85 };
86
87 Ok(content
88 .split("\n---\n")
89 .map(|s| s.trim().to_string())
90 .filter(|s| !s.is_empty())
91 .collect())
92 }
93
94 /// Write anchors back to the file, joined by "\n---\n".
95 fn write_anchors(app: &App, anchors: &[String]) -> Result<(), String> {
96 let path = anchors_path(app);
97
98 let content = anchors.join("\n---\n");
99 crate::fs_confined::write(&app.workspace, &path, content.as_bytes())
100 .map_err(|e| format!("Failed to write anchors file: {e}"))
101 }
102
103 fn add_anchor(app: &mut App, text: &str) -> CommandResult {
104 let path = anchors_path(app);
105
106 let mut file = match crate::fs_confined::open_append(&app.workspace, &path) {
107 Ok(f) => f,
108 Err(e) => {
109 return CommandResult::error(format!("Failed to open anchors file: {e}"));
110 }
111 };
112
113 // Write separator and anchor content.
114 if let Err(e) = writeln!(file, "\n---\n{text}") {
115 return CommandResult::error(format!("Failed to write anchor: {e}"));
116 }
117
118 CommandResult::message(format!("Anchor pinned.\nStored in: {}", path.display()))
119 }
120
121 fn list_anchors(app: &App) -> CommandResult {
122 let anchors = match read_anchors(app) {
123 Ok(anchors) => anchors,
124 Err(e) => return CommandResult::error(e),
125 };
126
127 if anchors.is_empty() {
128 return CommandResult::message(
129 "No anchors set. Use /anchor <text> to pin a fact that survives compaction.",
130 );
131 }
132
133 let mut output = format!("Pinned anchors ({} total):\n", anchors.len());
134 for (i, anchor) in anchors.iter().enumerate() {
135 output.push_str(&format!("\n {}. {}", i + 1, anchor));
136 }
137 output.push_str("\n\nUse /anchor remove <n> to remove an anchor.");
138
139 CommandResult::message(output)
140 }
141
142 fn remove_anchor(app: &mut App, index_str: &str) -> CommandResult {
143 let index: usize = match index_str.parse() {
144 Ok(n) if n >= 1 => n,
145 _ => {
146 return CommandResult::error(
147 "Invalid index. Use /anchor list to see anchor numbers, then /anchor remove <n>.",
148 );
149 }
150 };
151
152 let mut anchors = match read_anchors(app) {
153 Ok(anchors) => anchors,
154 Err(e) => return CommandResult::error(e),
155 };
156
157 if index > anchors.len() {
158 return CommandResult::error(format!(
159 "Anchor #{index} does not exist. You have {} anchor(s). Use /anchor list to see them.",
160 anchors.len()
161 ));
162 }
163
164 let removed = anchors.remove(index - 1);
165 if let Err(e) = write_anchors(app, &anchors) {
166 return CommandResult::error(e);
167 }
168
169 CommandResult::message(format!("Removed anchor #{index}: {removed}"))
170 }
171
172 #[cfg(test)]
173 mod tests {
174 use super::*;
175 use crate::config::Config;
176 use crate::tui::app::{App, TuiOptions};
177 use tempfile::TempDir;
178
179 fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
180 let options = TuiOptions {
181 skills_dir: tmpdir.path().join("skills"),
182 memory_path: tmpdir.path().join("memory.md"),
183 notes_path: tmpdir.path().join("notes.txt"),
184 mcp_config_path: tmpdir.path().join("mcp.json"),
185 ..crate::test_support::test_tui_options(tmpdir.path())
186 };
187 App::new(options, &Config::default())
188 }
189
190 #[cfg(unix)]
191 #[test]
192 fn confined_anchors_refuse_linked_files_and_directories() {
193 use std::os::unix::fs::symlink;
194
195 for directory in [".codewhale", ".deepseek"] {
196 for linked_directory in [false, true] {
197 let workspace = TempDir::new().unwrap();
198 let outside = TempDir::new().unwrap();
199 let mut app = create_test_app_with_tmpdir(&workspace);
200 let original = "first anchor\n---\nsecond anchor";
201 let target = outside.path().join("anchors.md");
202 std::fs::write(&target, original).unwrap();
203 let parent = workspace.path().join(directory);
204 if linked_directory {
205 symlink(outside.path(), &parent).unwrap();
206 } else {
207 std::fs::create_dir_all(&parent).unwrap();
208 symlink(&target, parent.join("anchors.md")).unwrap();
209 }
210 for command in ["list", "next anchor", "remove 1"] {
211 assert!(anchor(&mut app, Some(command)).is_error);
212 assert_eq!(std::fs::read_to_string(&target).unwrap(), original);
213 }
214 // Exercise the truncate path independently of the read refusal.
215 assert!(write_anchors(&app, &[]).is_err());
216 assert_eq!(std::fs::read_to_string(&target).unwrap(), original);
217 }
218 }
219 }
220
221 #[cfg(unix)]
222 #[test]
223 fn confined_anchors_refuse_dangling_links() {
224 for directory in [".codewhale", ".deepseek"] {
225 let workspace = TempDir::new().unwrap();
226 let outside = TempDir::new().unwrap();
227 let mut app = create_test_app_with_tmpdir(&workspace);
228 let target = outside.path().join("missing.md");
229 let parent = workspace.path().join(directory);
230 std::fs::create_dir_all(&parent).unwrap();
231 std::os::unix::fs::symlink(&target, parent.join("anchors.md")).unwrap();
232 assert!(anchor(&mut app, Some("list")).is_error);
233 assert!(anchor(&mut app, Some("next anchor")).is_error);
234 assert!(write_anchors(&app, &[]).is_err());
235 assert!(!target.exists());
236 }
237 }
238
239 #[test]
240 fn test_anchor_without_content_returns_error() {
241 let tmpdir = TempDir::new().unwrap();
242 let mut app = create_test_app_with_tmpdir(&tmpdir);
243 let result = anchor(&mut app, None);
244 assert!(result.is_error);
245 assert!(result.message.unwrap().contains("Usage:"));
246 }
247
248 #[test]
249 fn test_anchor_with_empty_content_returns_error() {
250 let tmpdir = TempDir::new().unwrap();
251 let mut app = create_test_app_with_tmpdir(&tmpdir);
252 let result = anchor(&mut app, Some(" "));
253 assert!(result.is_error);
254 assert!(result.message.unwrap().contains("Usage:"));
255 }
256
257 #[test]
258 fn test_anchor_add() {
259 let tmpdir = TempDir::new().unwrap();
260 let mut app = create_test_app_with_tmpdir(&tmpdir);
261 let result = anchor(&mut app, Some("API status field is unreliable"));
262 assert!(!result.is_error);
263 assert!(result.message.unwrap().contains("Anchor pinned"));
264
265 let path = tmpdir.path().join(".deepseek").join("anchors.md");
266 assert!(path.exists());
267 let content = std::fs::read_to_string(&path).unwrap();
268 assert!(content.contains("API status field is unreliable"));
269 }
270
271 #[test]
272 fn test_anchor_list_empty() {
273 let tmpdir = TempDir::new().unwrap();
274 let mut app = create_test_app_with_tmpdir(&tmpdir);
275 let result = anchor(&mut app, Some("list"));
276 assert!(!result.is_error);
277 assert!(result.message.unwrap().contains("No anchors set"));
278 }
279
280 #[test]
281 fn test_anchor_list_with_items() {
282 let tmpdir = TempDir::new().unwrap();
283 let mut app = create_test_app_with_tmpdir(&tmpdir);
284 anchor(&mut app, Some("First anchor"));
285 anchor(&mut app, Some("Second anchor"));
286
287 let result = anchor(&mut app, Some("list"));
288 let msg = result.message.unwrap();
289 assert!(msg.contains("2 total"));
290 assert!(msg.contains("1. First anchor"));
291 assert!(msg.contains("2. Second anchor"));
292 }
293
294 #[test]
295 fn test_anchor_remove() {
296 let tmpdir = TempDir::new().unwrap();
297 let mut app = create_test_app_with_tmpdir(&tmpdir);
298 anchor(&mut app, Some("First anchor"));
299 anchor(&mut app, Some("Second anchor"));
300
301 let result = anchor(&mut app, Some("remove 1"));
302 assert!(!result.is_error);
303 assert!(result.message.unwrap().contains("Removed anchor #1"));
304
305 let result = anchor(&mut app, Some("list"));
306 let msg = result.message.unwrap();
307 assert!(msg.contains("1 total"));
308 assert!(msg.contains("Second anchor"));
309 assert!(!msg.contains("First anchor"));
310 }
311
312 #[test]
313 fn test_anchor_remove_invalid_index() {
314 let tmpdir = TempDir::new().unwrap();
315 let mut app = create_test_app_with_tmpdir(&tmpdir);
316 anchor(&mut app, Some("Only anchor"));
317
318 let result = anchor(&mut app, Some("remove 5"));
319 assert!(result.is_error);
320 assert!(result.message.unwrap().contains("does not exist"));
321 }
322
323 #[test]
324 fn test_anchor_remove_non_numeric() {
325 let tmpdir = TempDir::new().unwrap();
326 let mut app = create_test_app_with_tmpdir(&tmpdir);
327 let result = anchor(&mut app, Some("remove abc"));
328 assert!(result.is_error);
329 assert!(result.message.unwrap().contains("Invalid index"));
330 }
331 }
332
332 lines RUST