apt-ostree/.notes/rpm-ostree/how-commands-work/05-search-command.md

18 KiB

Search Command Implementation Guide

Overview

The search command needs to be enhanced to use custom package search using libapt-pkg instead of relying on the apt search command, similar to how rpm-ostree implements its own search functionality.

Current Implementation Status

  • Basic search command exists in apt-ostree
  • Currently relies on apt search command
  • Missing custom libapt-pkg integration
  • Missing name and description search
  • Missing proper result formatting

Implementation Requirements

Phase 1: Custom Search Implementation

Files to Modify:

  • src/main.rs - Add search command options
  • src/apt.rs - Add custom search functionality
  • src/daemon.rs - Add search D-Bus method

Implementation Steps:

1.1 Update CLI Options (src/main.rs)

#[derive(Debug, Parser)]
pub struct SearchOpts {
    /// Search query
    query: String,
    
    /// Search in package descriptions
    #[arg(short = 'd', long)]
    description: bool,
    
    /// Search in package names only
    #[arg(short = 'n', long)]
    name_only: bool,
    
    /// Show package details
    #[arg(short = 'v', long)]
    verbose: bool,
    
    /// Output JSON format
    #[arg(long)]
    json: bool,
    
    /// Limit number of results
    #[arg(short = 'l', long)]
    limit: Option<usize>,
    
    /// Case insensitive search
    #[arg(short = 'i', long)]
    ignore_case: bool,
    
    /// Search in installed packages only
    #[arg(long)]
    installed_only: bool,
    
    /// Search in available packages only
    #[arg(long)]
    available_only: bool,
}

1.2 Add Custom Search Implementation (src/apt.rs)

use rust_apt::cache::Cache;
use rust_apt::package::Package;
use regex::Regex;

impl AptManager {
    pub async fn search_packages(&self, query: &str, opts: &SearchOpts) -> Result<Vec<SearchResult>, Box<dyn std::error::Error>> {
        // 1. Initialize APT cache
        let cache = Cache::new()?;
        
        // 2. Prepare search query
        let search_query = if opts.ignore_case {
            query.to_lowercase()
        } else {
            query.to_string()
        };
        
        // 3. Compile regex pattern
        let pattern = if opts.ignore_case {
            Regex::new(&format!("(?i){}", regex::escape(&search_query)))?
        } else {
            Regex::new(&regex::escape(&search_query))?
        };
        
        // 4. Search packages
        let mut results = Vec::new();
        
        for package in cache.packages() {
            if let Some(pkg) = package {
                // Check if package matches search criteria
                if self.matches_search_criteria(pkg, &pattern, opts).await? {
                    let result = self.create_search_result(pkg, opts).await?;
                    results.push(result);
                }
            }
        }
        
        // 5. Sort results by relevance
        results.sort_by(|a, b| {
            // Sort by exact name matches first, then by relevance score
            let a_exact = a.name == search_query;
            let b_exact = b.name == search_query;
            
            match (a_exact, b_exact) {
                (true, false) => std::cmp::Ordering::Less,
                (false, true) => std::cmp::Ordering::Greater,
                _ => b.relevance_score.cmp(&a.relevance_score),
            }
        });
        
        // 6. Apply limit if specified
        if let Some(limit) = opts.limit {
            results.truncate(limit);
        }
        
        Ok(results)
    }
    
    async fn matches_search_criteria(&self, package: &Package, pattern: &Regex, opts: &SearchOpts) -> Result<bool, Box<dyn std::error::Error>> {
        // Check installed/available filter
        if opts.installed_only && !package.is_installed() {
            return Ok(false);
        }
        
        if opts.available_only && package.is_installed() {
            return Ok(false);
        }
        
        // Search in package name
        let name = package.name();
        if pattern.is_match(name) {
            return Ok(true);
        }
        
        // Search in package description if requested
        if opts.description || !opts.name_only {
            if let Some(description) = package.long_description() {
                if pattern.is_match(description) {
                    return Ok(true);
                }
            }
            
            if let Some(short_description) = package.short_description() {
                if pattern.is_match(short_description) {
                    return Ok(true);
                }
            }
        }
        
        Ok(false)
    }
    
    async fn create_search_result(&self, package: &Package, opts: &SearchOpts) -> Result<SearchResult, Box<dyn std::error::Error>> {
        let name = package.name().to_string();
        let version = package.candidate_version()
            .map(|v| v.version().to_string())
            .unwrap_or_else(|| "unknown".to_string());
        
        let description = if opts.verbose {
            package.long_description().unwrap_or_else(|| "No description available".to_string())
        } else {
            package.short_description().unwrap_or_else(|| "No description available".to_string())
        };
        
        let architecture = package.candidate_version()
            .and_then(|v| v.architecture())
            .unwrap_or_else(|| "unknown".to_string());
        
        let installed_version = if package.is_installed() {
            package.installed_version()
                .map(|v| v.version().to_string())
        } else {
            None
        };
        
        let size = package.candidate_version()
            .and_then(|v| v.size())
            .unwrap_or(0);
        
        // Calculate relevance score
        let relevance_score = self.calculate_relevance_score(package, opts).await?;
        
        Ok(SearchResult {
            name,
            version,
            description,
            architecture,
            installed_version,
            size,
            relevance_score,
            is_installed: package.is_installed(),
        })
    }
    
    async fn calculate_relevance_score(&self, package: &Package, opts: &SearchOpts) -> Result<u32, Box<dyn std::error::Error>> {
        let mut score = 0;
        let query = opts.query.to_lowercase();
        let name = package.name().to_lowercase();
        
        // Exact name match gets highest score
        if name == query {
            score += 1000;
        }
        
        // Name starts with query
        if name.starts_with(&query) {
            score += 500;
        }
        
        // Name contains query
        if name.contains(&query) {
            score += 100;
        }
        
        // Description contains query
        if !opts.name_only {
            if let Some(description) = package.short_description() {
                let desc_lower = description.to_lowercase();
                if desc_lower.contains(&query) {
                    score += 50;
                }
            }
        }
        
        // Installed packages get slight bonus
        if package.is_installed() {
            score += 10;
        }
        
        Ok(score)
    }
}

#[derive(Debug, Clone)]
pub struct SearchResult {
    pub name: String,
    pub version: String,
    pub description: String,
    pub architecture: String,
    pub installed_version: Option<String>,
    pub size: u64,
    pub relevance_score: u32,
    pub is_installed: bool,
}

Phase 2: D-Bus Integration

Files to Modify:

  • src/daemon.rs - Add search D-Bus method
  • src/client.rs - Add search client method

Implementation Steps:

2.1 Add Search D-Bus Method (src/daemon.rs)

#[dbus_interface(name = "org.aptostree.dev")]
impl AptOstreeDaemon {
    /// Search for packages
    async fn search_packages(&self, query: String, options: HashMap<String, Value>) -> Result<Vec<SearchResult>, Box<dyn std::error::Error>> {
        let apt_manager = AptManager::new().await?;
        
        // Convert options to SearchOpts
        let opts = SearchOpts {
            query,
            description: options.get("description").and_then(|v| v.as_bool()).unwrap_or(false),
            name_only: options.get("name-only").and_then(|v| v.as_bool()).unwrap_or(false),
            verbose: options.get("verbose").and_then(|v| v.as_bool()).unwrap_or(false),
            json: options.get("json").and_then(|v| v.as_bool()).unwrap_or(false),
            limit: options.get("limit").and_then(|v| v.as_u64()).map(|l| l as usize),
            ignore_case: options.get("ignore-case").and_then(|v| v.as_bool()).unwrap_or(false),
            installed_only: options.get("installed-only").and_then(|v| v.as_bool()).unwrap_or(false),
            available_only: options.get("available-only").and_then(|v| v.as_bool()).unwrap_or(false),
        };
        
        let results = apt_manager.search_packages(&opts.query, &opts).await?;
        Ok(results)
    }
}

2.2 Add Search Client Method (src/client.rs)

impl AptOstreeClient {
    pub async fn search_packages(&self, query: &str, opts: &SearchOpts) -> Result<Vec<SearchResult>, Box<dyn std::error::Error>> {
        // Try daemon first
        if let Ok(results) = self.search_packages_via_daemon(query, opts).await {
            return Ok(results);
        }
        
        // Fallback to direct APT manager
        let apt_manager = AptManager::new().await?;
        apt_manager.search_packages(query, opts).await
    }
    
    async fn search_packages_via_daemon(&self, query: &str, opts: &SearchOpts) -> Result<Vec<SearchResult>, Box<dyn std::error::Error>> {
        let mut options = HashMap::new();
        options.insert("description".to_string(), Value::Bool(opts.description));
        options.insert("name-only".to_string(), Value::Bool(opts.name_only));
        options.insert("verbose".to_string(), Value::Bool(opts.verbose));
        options.insert("json".to_string(), Value::Bool(opts.json));
        options.insert("ignore-case".to_string(), Value::Bool(opts.ignore_case));
        options.insert("installed-only".to_string(), Value::Bool(opts.installed_only));
        options.insert("available-only".to_string(), Value::Bool(opts.available_only));
        
        if let Some(limit) = opts.limit {
            options.insert("limit".to_string(), Value::U64(limit as u64));
        }
        
        // Call daemon method
        let proxy = self.get_dbus_proxy().await?;
        let results: Vec<SearchResult> = proxy.search_packages(query.to_string(), options).await?;
        Ok(results)
    }
}

Phase 3: Result Formatting

Files to Modify:

  • src/formatting.rs - Add search result formatting
  • src/main.rs - Add search command formatting

Implementation Steps:

3.1 Add Search Result Formatting (src/formatting.rs)

impl SearchFormatter {
    pub fn format_search_results(&self, results: &[SearchResult], opts: &SearchOpts) -> String {
        if opts.json {
            self.format_json(results)
        } else {
            self.format_text(results, opts)
        }
    }
    
    fn format_json(&self, results: &[SearchResult]) -> String {
        let json_results: Vec<serde_json::Value> = results
            .iter()
            .map(|r| {
                let mut obj = serde_json::Map::new();
                obj.insert("name".to_string(), Value::String(r.name.clone()));
                obj.insert("version".to_string(), Value::String(r.version.clone()));
                obj.insert("description".to_string(), Value::String(r.description.clone()));
                obj.insert("architecture".to_string(), Value::String(r.architecture.clone()));
                obj.insert("size".to_string(), Value::Number(r.size.into()));
                obj.insert("is_installed".to_string(), Value::Bool(r.is_installed));
                obj.insert("relevance_score".to_string(), Value::Number(r.relevance_score.into()));
                
                if let Some(ref installed_version) = r.installed_version {
                    obj.insert("installed_version".to_string(), Value::String(installed_version.clone()));
                }
                
                Value::Object(obj)
            })
            .collect();
        
        serde_json::to_string_pretty(&Value::Array(json_results)).unwrap()
    }
    
    fn format_text(&self, results: &[SearchResult], opts: &SearchOpts) -> String {
        let mut output = String::new();
        
        if results.is_empty() {
            output.push_str("No packages found matching the search criteria.\n");
            return output;
        }
        
        // Print header
        output.push_str(&format!("Found {} packages:\n\n", results.len()));
        
        // Print results
        for result in results {
            output.push_str(&self.format_single_result(result, opts));
            output.push('\n');
        }
        
        output
    }
    
    fn format_single_result(&self, result: &SearchResult, opts: &SearchOpts) -> String {
        let mut output = String::new();
        
        // Package name and version
        let status_indicator = if result.is_installed { "[installed]" } else { "" };
        output.push_str(&format!("{}/{} {}\n", result.name, result.architecture, status_indicator));
        
        // Version information
        if let Some(ref installed_version) = result.installed_version {
            if installed_version != &result.version {
                output.push_str(&format!("  Installed: {}\n", installed_version));
                output.push_str(&format!("  Available: {}\n", result.version));
            } else {
                output.push_str(&format!("  Version: {}\n", result.version));
            }
        } else {
            output.push_str(&format!("  Version: {}\n", result.version));
        }
        
        // Size information
        if result.size > 0 {
            let size_mb = result.size as f64 / 1024.0 / 1024.0;
            output.push_str(&format!("  Size: {:.1} MB\n", size_mb));
        }
        
        // Description
        if !opts.name_only {
            output.push_str(&format!("  Description: {}\n", result.description));
        }
        
        output
    }
}

pub struct SearchFormatter;

3.2 Update Main Search Command (src/main.rs)

async fn search_command(opts: SearchOpts) -> Result<(), Box<dyn std::error::Error>> {
    // 1. Validate options
    if opts.installed_only && opts.available_only {
        return Err("Cannot specify both --installed-only and --available-only".into());
    }
    
    // 2. Perform search
    let client = AptOstreeClient::new().await?;
    let results = client.search_packages(&opts.query, &opts).await?;
    
    // 3. Format and display results
    let formatter = SearchFormatter;
    let output = formatter.format_search_results(&results, &opts);
    println!("{}", output);
    
    Ok(())
}

Testing Strategy

Unit Tests

#[cfg(test)]
mod tests {
    use super::*;
    
    #[tokio::test]
    async fn test_search_package_matching() {
        let apt_manager = AptManager::new().await.unwrap();
        let opts = SearchOpts {
            query: "test".to_string(),
            description: false,
            name_only: true,
            verbose: false,
            json: false,
            limit: None,
            ignore_case: true,
            installed_only: false,
            available_only: false,
        };
        
        let results = apt_manager.search_packages("test", &opts).await.unwrap();
        // Test based on available packages
    }
    
    #[tokio::test]
    async fn test_search_result_formatting() {
        let results = vec![
            SearchResult {
                name: "test-package".to_string(),
                version: "1.0-1".to_string(),
                description: "A test package".to_string(),
                architecture: "amd64".to_string(),
                installed_version: None,
                size: 1024,
                relevance_score: 100,
                is_installed: false,
            }
        ];
        
        let formatter = SearchFormatter;
        let opts = SearchOpts {
            query: "test".to_string(),
            description: false,
            name_only: false,
            verbose: false,
            json: false,
            limit: None,
            ignore_case: false,
            installed_only: false,
            available_only: false,
        };
        
        let output = formatter.format_search_results(&results, &opts);
        assert!(output.contains("test-package"));
        assert!(output.contains("A test package"));
    }
}

Integration Tests

#[tokio::test]
async fn test_search_command_integration() {
    let opts = SearchOpts {
        query: "htop".to_string(),
        description: false,
        name_only: false,
        verbose: false,
        json: false,
        limit: Some(10),
        ignore_case: true,
        installed_only: false,
        available_only: false,
    };
    
    let result = search_command(opts).await;
    assert!(result.is_ok());
}

Error Handling

Files to Modify:

  • src/error.rs - Add search-specific errors

Implementation:

#[derive(Debug, thiserror::Error)]
pub enum SearchError {
    #[error("Invalid search query: {0}")]
    InvalidQuery(String),
    
    #[error("Failed to initialize APT cache: {0}")]
    CacheError(String),
    
    #[error("Failed to compile search pattern: {0}")]
    PatternError(String),
    
    #[error("No packages found matching criteria")]
    NoResults,
    
    #[error("Search requires valid APT configuration")]
    AptConfigError,
}

impl From<SearchError> for Box<dyn std::error::Error> {
    fn from(err: SearchError) -> Self {
        Box::new(err)
    }
}

Dependencies to Add

Add to Cargo.toml:

[dependencies]
regex = "1.0"
serde_json = "1.0"

Implementation Checklist

  • Add CLI options for search command
  • Implement custom search using libapt-pkg
  • Add name and description search functionality
  • Implement relevance scoring
  • Add D-Bus integration for search
  • Add result formatting (text and JSON)
  • Add filtering options (installed/available)
  • Add comprehensive error handling
  • Write unit and integration tests
  • Update documentation

References

  • rpm-ostree search implementation patterns
  • libapt-pkg search functionality
  • APT package cache management
  • Regular expression search patterns