返回 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 every compaction cycle. 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::fs;
9 use std::io::Write;
10
11 use crate::commands::traits::{CommandInfo, RegisterCommand};
12 use crate::localization::MessageId;
13 use crate::tui::app::App;
14
15 use super::CommandResult;
16
17 const USAGE: &str = "/anchor <text> | /anchor list | /anchor remove <n>";
18
19 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
20 name: "anchor",
21 aliases: &["maodian"],
22 usage: USAGE,
23 description_id: MessageId::CmdAnchorDescription,
24 };
25
26 pub(in crate::commands) struct AnchorCmd;
27
28 impl RegisterCommand for AnchorCmd {
29 fn info() -> &'static CommandInfo {
30 &COMMAND_INFO
31 }
32
33 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
34 anchor(app, arg)
35 }
36 }
37
38 /// Handle the `/anchor` command with subcommands:
39 /// - `/anchor <text>` — add a new anchor
40 /// - `/anchor list` — list all anchors
41 /// - `/anchor remove <n>` — remove anchor by 1-based index
42 pub fn anchor(app: &mut App, content: Option<&str>) -> CommandResult {
43 let input = match content {
44 Some(c) => c.trim(),
45 None => {
46 return CommandResult::error(format!("Usage: {USAGE}"));
47 }
48 };
49
50 if input.is_empty() {
51 return CommandResult::error(format!("Usage: {USAGE}"));
52 }
53
54 // Parse subcommands.
55 if input.eq_ignore_ascii_case("list") {
56 return list_anchors(app);
57 }
58
59 if let Some(rest) = input
60 .strip_prefix("remove ")
61 .or_else(|| input.strip_prefix("rm "))
62 .or_else(|| input.strip_prefix("delete "))
63 {
64 return remove_anchor(app, rest.trim());
65 }
66
67 // Default: add a new anchor.
68 add_anchor(app, input)
69 }
70
71 fn anchors_path(app: &App) -> std::path::PathBuf {
72 let primary = app.workspace.join(".codewhale").join("anchors.md");
73 if primary.exists() {
74 return primary;
75 }
76 app.workspace.join(".deepseek").join("anchors.md")
77 }
78
79 /// Read and split anchors from the file. Each anchor is separated by "\n---\n".
80 fn read_anchors(app: &App) -> Vec<String> {
81 let path = anchors_path(app);
82 let content = match fs::read_to_string(&path) {
83 Ok(c) => c,
84 Err(_) => return Vec::new(),
85 };
86
87 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 if let Some(parent) = path.parent() {
99 fs::create_dir_all(parent)
100 .map_err(|e| format!("Failed to create anchors directory: {e}"))?;
101 }
102
103 let content = anchors.join("\n---\n");
104 fs::write(&path, content).map_err(|e| format!("Failed to write anchors file: {e}"))
105 }
106
107 fn add_anchor(app: &mut App, text: &str) -> CommandResult {
108 let path = anchors_path(app);
109
110 // Ensure parent directory exists.
111 if let Some(parent) = path.parent()
112 && let Err(e) = fs::create_dir_all(parent)
113 {
114 return CommandResult::error(format!("Failed to create anchors directory: {e}"));
115 }
116
117 // Append to anchors file.
118 let mut file = match fs::OpenOptions::new().create(true).append(true).open(&path) {
119 Ok(f) => f,
120 Err(e) => {
121 return CommandResult::error(format!("Failed to open anchors file: {e}"));
122 }
123 };
124
125 // Write separator and anchor content.
126 if let Err(e) = writeln!(file, "\n---\n{text}") {
127 return CommandResult::error(format!("Failed to write anchor: {e}"));
128 }
129
130 CommandResult::message(format!(
131 "Anchor pinned. It will be auto-injected into context after each compaction.\n\
132 Stored in: {}",
133 path.display()
134 ))
135 }
136
137 fn list_anchors(app: &App) -> CommandResult {
138 let anchors = read_anchors(app);
139
140 if anchors.is_empty() {
141 return CommandResult::message(
142 "No anchors set. Use /anchor <text> to pin a fact that survives compaction.",
143 );
144 }
145
146 let mut output = format!("Pinned anchors ({} total):\n", anchors.len());
147 for (i, anchor) in anchors.iter().enumerate() {
148 output.push_str(&format!("\n {}. {}", i + 1, anchor));
149 }
150 output.push_str("\n\nUse /anchor remove <n> to remove an anchor.");
151
152 CommandResult::message(output)
153 }
154
155 fn remove_anchor(app: &mut App, index_str: &str) -> CommandResult {
156 let index: usize = match index_str.parse() {
157 Ok(n) if n >= 1 => n,
158 _ => {
159 return CommandResult::error(
160 "Invalid index. Use /anchor list to see anchor numbers, then /anchor remove <n>.",
161 );
162 }
163 };
164
165 let mut anchors = read_anchors(app);
166
167 if index > anchors.len() {
168 return CommandResult::error(format!(
169 "Anchor #{index} does not exist. You have {} anchor(s). Use /anchor list to see them.",
170 anchors.len()
171 ));
172 }
173
174 let removed = anchors.remove(index - 1);
175 if let Err(e) = write_anchors(app, &anchors) {
176 return CommandResult::error(e);
177 }
178
179 CommandResult::message(format!("Removed anchor #{index}: {removed}"))
180 }
181
182 #[cfg(test)]
183 mod tests {
184 use super::*;
185 use crate::config::Config;
186 use crate::tui::app::{App, TuiOptions};
187 use tempfile::TempDir;
188
189 fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App {
190 let options = TuiOptions {
191 skills_dir: tmpdir.path().join("skills"),
192 memory_path: tmpdir.path().join("memory.md"),
193 notes_path: tmpdir.path().join("notes.txt"),
194 mcp_config_path: tmpdir.path().join("mcp.json"),
195 ..crate::test_support::test_tui_options(tmpdir.path())
196 };
197 App::new(options, &Config::default())
198 }
199
200 #[test]
201 fn test_anchor_without_content_returns_error() {
202 let tmpdir = TempDir::new().unwrap();
203 let mut app = create_test_app_with_tmpdir(&tmpdir);
204 let result = anchor(&mut app, None);
205 assert!(result.is_error);
206 assert!(result.message.unwrap().contains("Usage:"));
207 }
208
209 #[test]
210 fn test_anchor_with_empty_content_returns_error() {
211 let tmpdir = TempDir::new().unwrap();
212 let mut app = create_test_app_with_tmpdir(&tmpdir);
213 let result = anchor(&mut app, Some(" "));
214 assert!(result.is_error);
215 assert!(result.message.unwrap().contains("Usage:"));
216 }
217
218 #[test]
219 fn test_anchor_add() {
220 let tmpdir = TempDir::new().unwrap();
221 let mut app = create_test_app_with_tmpdir(&tmpdir);
222 let result = anchor(&mut app, Some("API status field is unreliable"));
223 assert!(!result.is_error);
224 assert!(result.message.unwrap().contains("Anchor pinned"));
225
226 let path = tmpdir.path().join(".deepseek").join("anchors.md");
227 assert!(path.exists());
228 let content = std::fs::read_to_string(&path).unwrap();
229 assert!(content.contains("API status field is unreliable"));
230 }
231
232 #[test]
233 fn test_anchor_list_empty() {
234 let tmpdir = TempDir::new().unwrap();
235 let mut app = create_test_app_with_tmpdir(&tmpdir);
236 let result = anchor(&mut app, Some("list"));
237 assert!(!result.is_error);
238 assert!(result.message.unwrap().contains("No anchors set"));
239 }
240
241 #[test]
242 fn test_anchor_list_with_items() {
243 let tmpdir = TempDir::new().unwrap();
244 let mut app = create_test_app_with_tmpdir(&tmpdir);
245 anchor(&mut app, Some("First anchor"));
246 anchor(&mut app, Some("Second anchor"));
247
248 let result = anchor(&mut app, Some("list"));
249 let msg = result.message.unwrap();
250 assert!(msg.contains("2 total"));
251 assert!(msg.contains("1. First anchor"));
252 assert!(msg.contains("2. Second anchor"));
253 }
254
255 #[test]
256 fn test_anchor_remove() {
257 let tmpdir = TempDir::new().unwrap();
258 let mut app = create_test_app_with_tmpdir(&tmpdir);
259 anchor(&mut app, Some("First anchor"));
260 anchor(&mut app, Some("Second anchor"));
261
262 let result = anchor(&mut app, Some("remove 1"));
263 assert!(!result.is_error);
264 assert!(result.message.unwrap().contains("Removed anchor #1"));
265
266 let result = anchor(&mut app, Some("list"));
267 let msg = result.message.unwrap();
268 assert!(msg.contains("1 total"));
269 assert!(msg.contains("Second anchor"));
270 assert!(!msg.contains("First anchor"));
271 }
272
273 #[test]
274 fn test_anchor_remove_invalid_index() {
275 let tmpdir = TempDir::new().unwrap();
276 let mut app = create_test_app_with_tmpdir(&tmpdir);
277 anchor(&mut app, Some("Only anchor"));
278
279 let result = anchor(&mut app, Some("remove 5"));
280 assert!(result.is_error);
281 assert!(result.message.unwrap().contains("does not exist"));
282 }
283
284 #[test]
285 fn test_anchor_remove_non_numeric() {
286 let tmpdir = TempDir::new().unwrap();
287 let mut app = create_test_app_with_tmpdir(&tmpdir);
288 let result = anchor(&mut app, Some("remove abc"));
289 assert!(result.is_error);
290 assert!(result.message.unwrap().contains("Invalid index"));
291 }
292 }
293
293 lines RUST