graphrag_cli/commands/
mod.rs1use color_eyre::eyre::{eyre, Result};
11use std::path::PathBuf;
12
13#[derive(Debug, Clone, PartialEq)]
15pub enum SlashCommand {
16 Config(PathBuf),
18 ConfigShow,
20 Load(PathBuf, bool), Clear,
24 Rebuild,
26 Stats,
28 Entities(Option<String>),
30 Reason(String),
32 Mode(String),
34 Export(PathBuf),
36 Workspace(String),
38 WorkspaceList,
40 WorkspaceSave(String),
42 WorkspaceDelete(String),
44 Help,
46}
47
48impl SlashCommand {
49 pub fn parse(input: &str) -> Result<Self> {
51 let trimmed = input.trim();
52
53 if !trimmed.starts_with('/') {
54 return Err(eyre!("Not a slash command (must start with /)"));
55 }
56
57 let parts: Vec<&str> = trimmed[1..].split_whitespace().collect();
58
59 if parts.is_empty() {
60 return Err(eyre!("Empty command"));
61 }
62
63 let command = parts[0].to_lowercase();
64 let args = &parts[1..];
65
66 match command.as_str() {
67 "config" => {
68 let path_str = trimmed[1..].trim_start_matches("config").trim();
71
72 if path_str.is_empty() {
73 return Err(eyre!("Missing argument: /config <file> or /config show"));
74 }
75
76 if path_str.eq_ignore_ascii_case("show") {
77 return Ok(SlashCommand::ConfigShow);
78 }
79
80 tracing::debug!("Parsing config command - path_str: {:?}", path_str);
82 Ok(SlashCommand::Config(PathBuf::from(path_str)))
83 },
84 "load" => {
85 let rest = trimmed[1..].trim_start_matches("load").trim();
87
88 if rest.is_empty() {
89 return Err(eyre!("Missing argument: /load <file> [--rebuild]"));
90 }
91
92 let rebuild = rest.contains("--rebuild") || rest.contains("-r");
94
95 let path_str = rest
97 .replace("--rebuild", "")
98 .replace("-r", "")
99 .trim()
100 .to_string();
101
102 if path_str.is_empty() {
103 return Err(eyre!("Missing file path argument"));
104 }
105
106 tracing::debug!(
107 "Parsing load command - path_str: {:?}, rebuild: {}",
108 path_str,
109 rebuild
110 );
111 Ok(SlashCommand::Load(PathBuf::from(path_str), rebuild))
112 },
113 "clear" => {
114 if !args.is_empty() {
115 return Err(eyre!("/clear takes no arguments"));
116 }
117 Ok(SlashCommand::Clear)
118 },
119 "rebuild" => {
120 if !args.is_empty() {
121 return Err(eyre!("/rebuild takes no arguments"));
122 }
123 Ok(SlashCommand::Rebuild)
124 },
125 "stats" => {
126 if !args.is_empty() {
127 return Err(eyre!("/stats takes no arguments"));
128 }
129 Ok(SlashCommand::Stats)
130 },
131 "entities" => {
132 let filter = if args.is_empty() {
133 None
134 } else {
135 Some(args.join(" "))
136 };
137 Ok(SlashCommand::Entities(filter))
138 },
139 "workspace" | "ws" => {
140 if args.is_empty() {
146 return Err(eyre!(
147 "Missing argument. Usage: /workspace <name|list|save|delete>"
148 ));
149 }
150
151 match args[0].to_lowercase().as_str() {
152 "list" | "ls" => {
153 if args.len() > 1 {
154 return Err(eyre!("/workspace list takes no additional arguments"));
155 }
156 Ok(SlashCommand::WorkspaceList)
157 },
158 "save" => {
159 if args.len() < 2 {
160 return Err(eyre!("Missing workspace name: /workspace save <name>"));
161 }
162 Ok(SlashCommand::WorkspaceSave(args[1].to_string()))
163 },
164 "delete" | "del" | "rm" => {
165 if args.len() < 2 {
166 return Err(eyre!("Missing workspace name: /workspace delete <name>"));
167 }
168 Ok(SlashCommand::WorkspaceDelete(args[1].to_string()))
169 },
170 name => {
171 Ok(SlashCommand::Workspace(name.to_string()))
173 },
174 }
175 },
176 "reason" => {
177 let q = args.join(" ");
178 if q.is_empty() {
179 return Err(eyre!("Missing query: /reason <your question>"));
180 }
181 Ok(SlashCommand::Reason(q))
182 },
183 "mode" => {
184 if args.is_empty() {
185 return Err(eyre!("Usage: /mode ask|explain|reason"));
186 }
187 Ok(SlashCommand::Mode(args[0].to_lowercase()))
188 },
189 "export" => {
190 let rest = trimmed[1..].trim_start_matches("export").trim();
191 if rest.is_empty() {
192 return Err(eyre!("Missing path: /export <file.md>"));
193 }
194 Ok(SlashCommand::Export(PathBuf::from(rest)))
195 },
196 "help" => {
197 if !args.is_empty() {
198 return Err(eyre!("/help takes no arguments"));
199 }
200 Ok(SlashCommand::Help)
201 },
202 _ => Err(eyre!(
203 "Unknown command: /{}. Type /help for available commands.",
204 command
205 )),
206 }
207 }
208
209 pub fn help_text() -> String {
211 r#"
212Available Slash Commands:
213━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
214
215/config <file> Load GraphRAG configuration file
216 Supports: JSON5, JSON, TOML
217 Example: /config docs-example/sym.json5
218
219/config show Display the currently loaded configuration file
220
221/load <file> [--rebuild] Load and process a document into the knowledge graph
222 --rebuild: Clear existing graph before building
223 Example: /load info/Symposium.txt
224 Example: /load info/Symposium.txt --rebuild
225
226/clear Clear the knowledge graph (preserves documents)
227 Removes all entities and relationships
228
229/rebuild Rebuild the knowledge graph from loaded documents
230 Clears graph and re-extracts entities/relationships
231 Useful after changing configuration or to fix issues
232
233/stats Show knowledge graph statistics
234 Displays: entities, relationships, documents, chunks
235
236/entities [filter] List entities in the knowledge graph
237 Example: /entities socrates
238 Example: /entities PERSON
239
240/reason <query> Execute a one-shot reasoning query (query decomposition)
241 Splits complex questions into sub-queries for better answers
242 Example: /reason Compare the main themes of the book
243
244/mode ask|explain|reason Switch the default query mode (sticky until changed)
245 ask: Plain answer (fastest, no metadata)
246 explain: Answer + confidence score + source references
247 reason: Query decomposition for complex multi-part questions
248 Example: /mode explain
249
250/export <file.md> Export query history to a Markdown file
251 Example: /export /tmp/my_session.md
252
253/workspace <command> Workspace management commands:
254 /ws list List all available workspaces with statistics
255 /ws save <name> Save current graph to a workspace
256 /ws <name> Load graph from a workspace
257 /ws delete <name> Delete a workspace permanently
258
259 Examples:
260 /workspace list
261 /workspace save my_project
262 /workspace my_project
263 /workspace delete old_project
264
265/help Show this help message
266
267━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
268
269Keyboard Shortcuts:
270━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
271
272FOCUS & NAVIGATION:
273F1 Focus Results Viewer (LLM answer)
274F2 Focus Raw Search Results
275F3 Focus Info Panel (Tab cycles tabs within)
276Esc Return focus to Input (enable typing)
277
278INFO PANEL TABS (when F3 focused):
279Tab Cycle tabs: Stats → Sources → History
280j / k Scroll within Sources or History tab
281
282SCROLLING (when Results/Raw viewer is focused):
283j / k Scroll down / up one line
284Ctrl+D / Ctrl+U Scroll down / up one page
285Home / End Scroll to top / bottom
286
287OTHER:
288Ctrl+C / Ctrl+Q Quit application
289? Toggle help
290
291━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
292
293Tip: Default mode is ASK. Use /mode explain for confidence scores and sources.
294Tip: After an EXPLAIN query, the Sources tab in the Info Panel auto-opens.
295Tip: Use --rebuild flag to force a fresh graph rebuild when loading documents.
296"#
297 .trim()
298 .to_string()
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn test_parse_config() {
308 let cmd = SlashCommand::parse("/config test.toml").unwrap();
309 assert_eq!(cmd, SlashCommand::Config(PathBuf::from("test.toml")));
310 }
311
312 #[test]
313 fn test_parse_config_with_path() {
314 let cmd = SlashCommand::parse("/config docs-example/sym.json5").unwrap();
315 assert_eq!(
316 cmd,
317 SlashCommand::Config(PathBuf::from("docs-example/sym.json5"))
318 );
319 }
320
321 #[test]
322 fn test_parse_config_with_spaces_in_dirname() {
323 let cmd = SlashCommand::parse("/config my docs/config.toml").unwrap();
324 assert_eq!(
325 cmd,
326 SlashCommand::Config(PathBuf::from("my docs/config.toml"))
327 );
328 }
329
330 #[test]
331 fn test_parse_load() {
332 let cmd = SlashCommand::parse("/load doc.txt").unwrap();
333 assert_eq!(cmd, SlashCommand::Load(PathBuf::from("doc.txt"), false));
334 }
335
336 #[test]
337 fn test_parse_load_with_rebuild() {
338 let cmd = SlashCommand::parse("/load doc.txt --rebuild").unwrap();
339 assert_eq!(cmd, SlashCommand::Load(PathBuf::from("doc.txt"), true));
340 }
341
342 #[test]
343 fn test_parse_load_with_rebuild_short() {
344 let cmd = SlashCommand::parse("/load doc.txt -r").unwrap();
345 assert_eq!(cmd, SlashCommand::Load(PathBuf::from("doc.txt"), true));
346 }
347
348 #[test]
349 fn test_parse_clear() {
350 let cmd = SlashCommand::parse("/clear").unwrap();
351 assert_eq!(cmd, SlashCommand::Clear);
352 }
353
354 #[test]
355 fn test_parse_rebuild() {
356 let cmd = SlashCommand::parse("/rebuild").unwrap();
357 assert_eq!(cmd, SlashCommand::Rebuild);
358 }
359
360 #[test]
361 fn test_parse_stats() {
362 let cmd = SlashCommand::parse("/stats").unwrap();
363 assert_eq!(cmd, SlashCommand::Stats);
364 }
365
366 #[test]
367 fn test_parse_entities_no_filter() {
368 let cmd = SlashCommand::parse("/entities").unwrap();
369 assert_eq!(cmd, SlashCommand::Entities(None));
370 }
371
372 #[test]
373 fn test_parse_entities_with_filter() {
374 let cmd = SlashCommand::parse("/entities socrates").unwrap();
375 assert_eq!(cmd, SlashCommand::Entities(Some("socrates".to_string())));
376 }
377
378 #[test]
379 fn test_parse_workspace() {
380 let cmd = SlashCommand::parse("/workspace test").unwrap();
381 assert_eq!(cmd, SlashCommand::Workspace("test".to_string()));
382 }
383
384 #[test]
385 fn test_parse_help() {
386 let cmd = SlashCommand::parse("/help").unwrap();
387 assert_eq!(cmd, SlashCommand::Help);
388 }
389
390 #[test]
391 fn test_parse_unknown_command() {
392 let result = SlashCommand::parse("/unknown");
393 assert!(result.is_err());
394 }
395
396 #[test]
397 fn test_parse_not_slash_command() {
398 let result = SlashCommand::parse("config test.toml");
399 assert!(result.is_err());
400 }
401
402 #[test]
403 fn test_parse_missing_arguments() {
404 assert!(SlashCommand::parse("/config").is_err());
405 assert!(SlashCommand::parse("/load").is_err());
406 assert!(SlashCommand::parse("/workspace").is_err());
407 }
408}