syntaxis/shared/rust/project_selector.rs
Jesús Pérez 9cef9b8d57 refactor: consolidate configuration directories
Merge _configs/ into config/ for single configuration directory.
Update all path references.

Changes:
- Move _configs/* to config/
- Update .gitignore for new patterns
- No code references to _configs/ found

Impact: -1 root directory (layout_conventions.md compliance)
2025-12-26 18:36:23 +00:00

197 lines
5.6 KiB
Rust

//! Interactive project selection using inquire
//!
//! Provides a terminal UI for selecting projects when auto-detection is not possible.
//! Uses arrow keys for navigation (requires "interactive" feature).
//!
//! # Features
//! Requires the "interactive" feature to be enabled:
//!
//! ```toml
//! [dependencies]
//! tools-shared = { version = "0.1", features = ["interactive"] }
//! ```
//!
//! # Examples
//! ```no_run
//! use tools_shared::{select_project, SelectableProject};
//!
//! let projects = vec![
//! SelectableProject {
//! id: "my-project".to_string(),
//! name: "My Project".to_string(),
//! path: Some("/Users/akasha/my-project".to_string()),
//! metadata: Some("Phase: Devel".to_string()),
//! },
//! ];
//!
//! let selected = select_project(projects, "Select a project:", None)?;
//! println!("Selected: {}", selected);
//! # Ok::<(), Box<dyn std::error::Error>>(())
//! ```
/// Project information for interactive selection
#[derive(Debug, Clone)]
pub struct SelectableProject {
/// Project ID (used internally)
pub id: String,
/// Display name
pub name: String,
/// Optional project path
pub path: Option<String>,
/// Optional metadata (e.g., "Phase: Devel")
pub metadata: Option<String>,
}
#[cfg(feature = "interactive")]
/// Show interactive project selector using inquire
///
/// Displays projects with arrow key navigation and returns selected project ID.
///
/// # Arguments
/// * `projects` - List of selectable projects
/// * `prompt` - Prompt text to display
/// * `default_id` - Optional default project ID (pre-selected)
///
/// # Returns
/// Selected project ID
///
/// # Errors
/// Returns error if user cancels selection or inquire fails
///
/// # Examples
/// ```no_run
/// use tools_shared::{select_project, SelectableProject};
///
/// let projects = vec![
/// SelectableProject {
/// id: "proj1".to_string(),
/// name: "Project 1".to_string(),
/// path: Some("/path/to/proj1".to_string()),
/// metadata: None,
/// },
/// ];
///
/// let selected = select_project(projects, "Choose project:", None)?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
pub fn select_project(
projects: Vec<SelectableProject>,
prompt: &str,
default_id: Option<&str>,
) -> Result<String, Box<dyn std::error::Error>> {
use inquire::Select;
if projects.is_empty() {
return Err("No projects available".into());
}
// Format options for display
let options: Vec<String> = projects.iter().map(format_project_option).collect();
// Find default index
let default_index = default_id
.and_then(|id| projects.iter().position(|p| p.id == id))
.unwrap_or(0);
// Show selector
let selection = Select::new(prompt, options)
.with_starting_cursor(default_index)
.prompt()?;
// Find and return the ID of selected project
let selected_name = selection.split("").next().unwrap_or(&selection);
let selected_project = projects
.iter()
.find(|p| p.name == selected_name)
.ok_or("Project not found")?;
Ok(selected_project.id.clone())
}
/// Format project for display in selector
///
/// Creates a readable option string with project name, path, and metadata.
///
/// # Examples
/// ```
/// use tools_shared::{format_project_option, SelectableProject};
///
/// let project = SelectableProject {
/// id: "proj1".to_string(),
/// name: "My Project".to_string(),
/// path: Some("/home/user/my-project".to_string()),
/// metadata: Some("Phase: Devel".to_string()),
/// };
///
/// let formatted = format_project_option(&project);
/// assert!(formatted.contains("My Project"));
/// ```
pub fn format_project_option(project: &SelectableProject) -> String {
let mut parts = vec![project.name.clone()];
if let Some(ref path) = project.path {
parts.push(format!("({})", path));
}
if let Some(ref metadata) = project.metadata {
parts.push(metadata.clone());
}
parts.join("")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_project_option_full() {
let project = SelectableProject {
id: "proj1".to_string(),
name: "My Project".to_string(),
path: Some("/home/user/project".to_string()),
metadata: Some("Phase: Devel".to_string()),
};
let formatted = format_project_option(&project);
assert!(formatted.contains("My Project"));
assert!(formatted.contains("/home/user/project"));
assert!(formatted.contains("Phase: Devel"));
assert!(formatted.contains(""));
}
#[test]
fn test_format_project_option_minimal() {
let project = SelectableProject {
id: "proj1".to_string(),
name: "My Project".to_string(),
path: None,
metadata: None,
};
let formatted = format_project_option(&project);
assert_eq!(formatted, "My Project");
}
#[test]
fn test_format_project_option_with_metadata() {
let project = SelectableProject {
id: "proj1".to_string(),
name: "My Project".to_string(),
path: None,
metadata: Some("Status: Active".to_string()),
};
let formatted = format_project_option(&project);
assert!(formatted.contains("My Project"));
assert!(formatted.contains("Status: Active"));
}
#[test]
#[cfg(feature = "interactive")]
fn test_select_project_error_on_empty() {
let result = select_project(vec![], "Select project:", None);
assert!(result.is_err());
}
}