Skip to main content

metis_docs_cli/commands/
search.rs

1use crate::commands::list::OutputFormat;
2use crate::workspace;
3use anyhow::Result;
4use clap::Args;
5use metis_core::dal::database::models::Document;
6use metis_core::{Application, Database};
7use serde::Serialize;
8
9#[derive(Args)]
10pub struct SearchCommand {
11    /// Search query for full-text search across document content
12    pub query: String,
13
14    /// Maximum number of results to show
15    #[arg(short = 'l', long, default_value = "20")]
16    pub limit: usize,
17
18    /// Output format (table, compact, json)
19    #[arg(short = 'f', long, value_enum, default_value = "table")]
20    pub format: OutputFormat,
21}
22
23/// JSON-serializable search result for output
24#[derive(Serialize)]
25struct SearchResultOutput {
26    code: String,
27    title: String,
28    #[serde(rename = "type")]
29    doc_type: String,
30}
31
32impl SearchCommand {
33    pub async fn execute(&self) -> Result<()> {
34        // 1. Validate we're in a metis workspace
35        let (workspace_exists, metis_dir) = workspace::has_metis_vault();
36        if !workspace_exists {
37            anyhow::bail!("Not in a Metis workspace. Run 'metis init' to create one.");
38        }
39        let metis_dir = metis_dir.unwrap();
40
41        // 2. Sync before searching to catch external edits
42        let db_path = metis_dir.join("metis.db");
43        let database = Database::new(db_path.to_str().unwrap())
44            .map_err(|e| anyhow::anyhow!("Failed to open database for sync: {}", e))?;
45        let app = Application::new(database);
46        app.sync_directory(&metis_dir)
47            .await
48            .map_err(|e| anyhow::anyhow!("Failed to sync workspace: {}", e))?;
49
50        // 3. Initialize the database and application for search
51        let database = Database::new(db_path.to_str().unwrap())
52            .map_err(|e| anyhow::anyhow!("Failed to open database: {}", e))?;
53        let mut app = Application::new(database);
54
55        // 4. Perform full-text search
56        let results = self.perform_search(&mut app, &self.query)?;
57
58        // 5. Limit results
59        let limited_results: Vec<_> = results.into_iter().take(self.limit).collect();
60
61        // 6. Display results based on format
62        if limited_results.is_empty() {
63            match self.format {
64                OutputFormat::Json => println!("[]"),
65                _ => println!("No documents found for query: \"{}\"", self.query),
66            }
67            return Ok(());
68        }
69
70        match self.format {
71            OutputFormat::Table => self.display_table(&limited_results),
72            OutputFormat::Compact => self.display_compact(&limited_results),
73            OutputFormat::Json => self.display_json(&limited_results),
74        }
75
76        Ok(())
77    }
78
79    fn perform_search(&self, app: &mut Application, query: &str) -> Result<Vec<Document>> {
80        app.with_database(|db_service| db_service.search_documents(query))
81            .map_err(|e| anyhow::anyhow!("Search failed: {}", e))
82    }
83
84    /// Display results as a human-readable table
85    /// Columns match MCP search_documents: Code, Title, Type
86    fn display_table(&self, documents: &[Document]) {
87        println!(
88            "\n{:<14} {:<60} {:<12}",
89            "Code", "Title", "Type"
90        );
91        println!("{}", "-".repeat(88));
92
93        for doc in documents {
94            println!(
95                "{:<14} {:<60} {:<12}",
96                doc.short_code,
97                truncate(&doc.title, 58),
98                doc.document_type
99            );
100        }
101
102        println!(
103            "\nFound {} document(s) for \"{}\"",
104            documents.len(),
105            self.query
106        );
107    }
108
109    /// Display results in compact format (one line per document)
110    /// Format: CODE TYPE TITLE
111    fn display_compact(&self, documents: &[Document]) {
112        for doc in documents {
113            println!("{} {} {}", doc.short_code, doc.document_type, doc.title);
114        }
115    }
116
117    /// Display results as JSON array
118    fn display_json(&self, documents: &[Document]) {
119        let output: Vec<SearchResultOutput> = documents
120            .iter()
121            .map(|doc| SearchResultOutput {
122                code: doc.short_code.clone(),
123                title: doc.title.clone(),
124                doc_type: doc.document_type.clone(),
125            })
126            .collect();
127
128        match serde_json::to_string_pretty(&output) {
129            Ok(json) => println!("{}", json),
130            Err(e) => eprintln!("Error serializing to JSON: {}", e),
131        }
132    }
133}
134
135// Helper function
136fn truncate(s: &str, max_len: usize) -> String {
137    if s.len() <= max_len {
138        s.to_string()
139    } else {
140        format!("{}...", &s[..max_len.saturating_sub(3)])
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn test_truncate() {
150        assert_eq!(truncate("short", 10), "short");
151        assert_eq!(truncate("this is a very long string", 10), "this is...");
152        assert_eq!(truncate("exactly_10", 10), "exactly_10");
153    }
154}