From 8389b0d6524bcafd61ffef4544eff8816da98bee Mon Sep 17 00:00:00 2001 From: "Dhanji R. Prasanna" Date: Tue, 27 Jan 2026 12:43:24 +1100 Subject: [PATCH] Add TAB autocompletion for /project command - Complete project names from ~/projects/ directory - Display shows project name, replacement uses ~/projects/ path - Projects sorted alphabetically - Added test for project completion --- crates/g3-cli/src/completion.rs | 77 +++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/crates/g3-cli/src/completion.rs b/crates/g3-cli/src/completion.rs index 6174bc8..fa7e18b 100644 --- a/crates/g3-cli/src/completion.rs +++ b/crates/g3-cli/src/completion.rs @@ -5,6 +5,7 @@ //! - Command completion for `/` commands at line start //! - File path completion for `./`, `../`, `~/`, `/` prefixes //! - Session ID completion for `/resume` command +//! - Project name completion for `/project` command (from ~/projects/) use rustyline::completion::{Completer, FilenameCompleter, Pair}; use rustyline::error::ReadlineError; @@ -153,6 +154,33 @@ impl G3Helper { sessions } + + /// List project directories from ~/projects/, sorted alphabetically. + fn list_projects(&self, prefix: &str) -> Vec { + let projects_dir = match dirs::home_dir() { + Some(home) => home.join("projects"), + None => return Vec::new(), + }; + + if !projects_dir.is_dir() { + return Vec::new(); + } + + let mut projects: Vec = std::fs::read_dir(&projects_dir) + .ok() + .map(|entries| { + entries + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.path().is_dir()) + .filter_map(|entry| Some(entry.file_name().to_string_lossy().to_string())) + .filter(|name| name.starts_with(prefix)) + .collect() + }) + .unwrap_or_default(); + + projects.sort(); + projects + } } impl Default for G3Helper { @@ -261,6 +289,23 @@ impl Completer for G3Helper { return Ok((word_start, matches)); } + // Case 5: Project name completion for /project command + if line_to_cursor.starts_with("/project ") { + let partial = word; + let projects = self.list_projects(partial); + let matches: Vec = projects + .into_iter() + .map(|name| { + let full_path = format!("~/projects/{}", name); + Pair { + display: name, + replacement: full_path, + } + }) + .collect(); + return Ok((word_start, matches)); + } + // No completion for regular text Ok((pos, vec![])) } @@ -502,4 +547,36 @@ mod tests { let sessions = helper.list_sessions(None); let _ = sessions; // Just verify no panic } + + #[test] + fn test_project_completion_lists_projects() { + let helper = G3Helper::new(); + let history = rustyline::history::DefaultHistory::new(); + let ctx = Context::new(&history); + + let line = "/project "; + let pos = line.len(); + let (start, completions) = helper.complete(line, pos, &ctx).unwrap(); + let _ = start; + + // If ~/projects exists and has directories, we should get completions + if let Some(home) = dirs::home_dir() { + let projects_dir = home.join("projects"); + if projects_dir.is_dir() { + // Verify completions have the right format (display is name, replacement is ~/projects/name) + for completion in &completions { + assert!(completion.replacement.starts_with("~/projects/"), + "Replacement should start with ~/projects/, got: {}", completion.replacement); + assert!(!completion.display.contains('/'), + "Display should be just the project name, got: {}", completion.display); + } + } + } + + // Test with a prefix that won't match anything + let line = "/project zzz_nonexistent_prefix_"; + let pos = line.len(); + let (_, completions) = helper.complete(line, pos, &ctx).unwrap(); + assert_eq!(completions.len(), 0, "Non-matching prefix should return empty"); + } }