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)
197 lines
5.6 KiB
Rust
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());
|
|
}
|
|
}
|