dot_agent_core/channel/
search.rs

1//! Channel search functionality
2//!
3//! Provides search capabilities across different channel types.
4
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::process::Command;
8
9use crate::error::{DotAgentError, Result};
10
11use super::channel_registry::ChannelRegistry;
12use super::types::{Channel, ChannelType, ProfileRef, SearchOptions};
13
14/// Channel manager for search operations
15pub struct ChannelManager {
16    base_dir: PathBuf,
17    registry: ChannelRegistry,
18}
19
20impl ChannelManager {
21    /// Create a new channel manager
22    pub fn new(base_dir: PathBuf) -> Result<Self> {
23        let registry = ChannelRegistry::load(&base_dir)?;
24        Ok(Self { base_dir, registry })
25    }
26
27    /// Create with a specific registry
28    pub fn with_registry(base_dir: PathBuf, registry: ChannelRegistry) -> Self {
29        Self { base_dir, registry }
30    }
31
32    /// Get the registry
33    pub fn registry(&self) -> &ChannelRegistry {
34        &self.registry
35    }
36
37    /// Get mutable registry
38    pub fn registry_mut(&mut self) -> &mut ChannelRegistry {
39        &mut self.registry
40    }
41
42    /// Save registry
43    pub fn save(&self) -> Result<()> {
44        self.registry.save(&self.base_dir)
45    }
46
47    /// Search across all enabled searchable channels
48    pub fn search(&self, query: &str, options: &SearchOptions) -> Result<Vec<ProfileRef>> {
49        let mut results = Vec::new();
50
51        // Get channels to search
52        let channels: Vec<&Channel> = if options.channels.is_empty() {
53            self.registry.list_searchable()
54        } else {
55            self.registry
56                .list_enabled()
57                .into_iter()
58                .filter(|c| options.channels.contains(&c.name))
59                .collect()
60        };
61
62        for channel in channels {
63            match self.search_channel(channel, query, options) {
64                Ok(refs) => results.extend(refs),
65                Err(e) => {
66                    eprintln!("Warning: {} search failed: {}", channel.name, e);
67                }
68            }
69        }
70
71        // Sort by stars (descending)
72        results.sort_by(|a, b| b.stars.cmp(&a.stars));
73
74        // Apply limit
75        if options.limit > 0 {
76            results.truncate(options.limit);
77        }
78
79        Ok(results)
80    }
81
82    /// Search a specific channel
83    fn search_channel(
84        &self,
85        channel: &Channel,
86        query: &str,
87        options: &SearchOptions,
88    ) -> Result<Vec<ProfileRef>> {
89        match channel.channel_type {
90            ChannelType::GitHubGlobal => self.search_github(channel, query, options),
91            ChannelType::AwesomeList => self.search_awesome_list(channel, query, options),
92            ChannelType::Hub | ChannelType::Direct => {
93                // Not searchable
94                Ok(Vec::new())
95            }
96        }
97    }
98
99    /// Search GitHub globally
100    fn search_github(
101        &self,
102        channel: &Channel,
103        query: &str,
104        options: &SearchOptions,
105    ) -> Result<Vec<ProfileRef>> {
106        // Check if gh CLI is available
107        if !Self::check_gh_available() {
108            return Err(DotAgentError::GitHubCliNotFound);
109        }
110
111        let mut args = vec!["search".to_string(), "repos".to_string()];
112
113        // Build query
114        let search_query = if options.keywords.is_empty() {
115            query.to_string()
116        } else {
117            format!("{} {}", query, options.keywords.join(" "))
118        };
119
120        if !search_query.is_empty() {
121            args.push(search_query);
122        }
123
124        // Topic filter
125        if let Some(topic) = &options.topic {
126            args.push("--topic".to_string());
127            args.push(topic.clone());
128        }
129
130        // Sort order
131        args.push("--sort".to_string());
132        args.push(options.sort.clone().unwrap_or_else(|| "stars".to_string()));
133
134        // Limit (per channel)
135        let limit = if options.limit > 0 { options.limit } else { 10 };
136        args.push("--limit".to_string());
137        args.push(limit.to_string());
138
139        // Stars filter
140        if let Some(min) = options.min_stars {
141            args.push("--stars".to_string());
142            args.push(format!(">={}", min));
143        }
144
145        // JSON output
146        args.push("--json".to_string());
147        args.push("name,owner,description,url,stargazersCount".to_string());
148
149        let output = Command::new("gh").args(&args).output().map_err(|e| {
150            if e.kind() == std::io::ErrorKind::NotFound {
151                DotAgentError::GitHubCliNotFound
152            } else {
153                DotAgentError::Io(e)
154            }
155        })?;
156
157        if !output.status.success() {
158            let stderr = String::from_utf8_lossy(&output.stderr);
159            return Err(DotAgentError::GitHubApiError {
160                message: stderr.to_string(),
161            });
162        }
163
164        let json: serde_json::Value =
165            serde_json::from_slice(&output.stdout).unwrap_or(serde_json::Value::Array(vec![]));
166
167        let repos = json
168            .as_array()
169            .ok_or_else(|| DotAgentError::GitHubApiError {
170                message: "Invalid JSON response".to_string(),
171            })?;
172
173        let mut results = Vec::new();
174
175        for repo in repos {
176            let name = repo["name"].as_str().unwrap_or("").to_string();
177            let owner = repo["owner"]["login"].as_str().unwrap_or("").to_string();
178            let description = repo["description"]
179                .as_str()
180                .unwrap_or("No description")
181                .to_string();
182            let url = repo["url"].as_str().unwrap_or("").to_string();
183            let stars = repo["stargazersCount"].as_u64();
184
185            let id = format!("github:{}/{}", owner, name);
186
187            results.push(ProfileRef {
188                id,
189                name,
190                owner,
191                description,
192                url,
193                stars,
194                channel: channel.name.clone(),
195                metadata: HashMap::new(),
196            });
197        }
198
199        Ok(results)
200    }
201
202    /// Search an Awesome List
203    fn search_awesome_list(
204        &self,
205        channel: &Channel,
206        query: &str,
207        options: &SearchOptions,
208    ) -> Result<Vec<ProfileRef>> {
209        let url = match channel.source.url() {
210            Some(u) => u,
211            None => return Ok(Vec::new()),
212        };
213
214        // Get cached content or fetch
215        let content = self.fetch_awesome_list(url, &channel.name)?;
216
217        // Parse and search
218        let query_lower = query.to_lowercase();
219        let keywords: Vec<String> = options.keywords.iter().map(|k| k.to_lowercase()).collect();
220
221        let mut results = Vec::new();
222
223        for line in content.lines() {
224            if let Some(profile_ref) = Self::parse_awesome_line(line, &channel.name) {
225                // Check if matches query
226                let text = format!(
227                    "{} {} {}",
228                    profile_ref.name, profile_ref.owner, profile_ref.description
229                )
230                .to_lowercase();
231
232                let matches_query = query.is_empty() || text.contains(&query_lower);
233                let matches_keywords =
234                    keywords.is_empty() || keywords.iter().all(|k| text.contains(k));
235
236                if matches_query && matches_keywords {
237                    results.push(profile_ref);
238                }
239            }
240        }
241
242        // Apply limit
243        if options.limit > 0 {
244            results.truncate(options.limit);
245        }
246
247        Ok(results)
248    }
249
250    /// Fetch Awesome List content (with caching)
251    fn fetch_awesome_list(&self, url: &str, channel_name: &str) -> Result<String> {
252        let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
253        let cache_file = cache_dir.join("content.md");
254
255        // Check cache
256        if cache_file.exists() {
257            if let Ok(content) = std::fs::read_to_string(&cache_file) {
258                return Ok(content);
259            }
260        }
261
262        // Fetch from URL
263        let raw_url = Self::to_raw_url(url);
264        let content = Self::fetch_url(&raw_url)?;
265
266        // Cache it
267        std::fs::create_dir_all(&cache_dir)?;
268        std::fs::write(&cache_file, &content)?;
269
270        Ok(content)
271    }
272
273    /// Parse a line from Awesome List markdown
274    fn parse_awesome_line(line: &str, channel_name: &str) -> Option<ProfileRef> {
275        // Format: - [name](url) - description
276        // or: * [name](url) - description
277        let line = line.trim();
278
279        if !line.starts_with('-') && !line.starts_with('*') {
280            return None;
281        }
282
283        // Find [name](url)
284        let start_bracket = line.find('[')?;
285        let end_bracket = line.find(']')?;
286        let start_paren = line.find('(')?;
287        let end_paren = line.find(')')?;
288
289        if end_bracket >= start_paren || start_paren >= end_paren {
290            return None;
291        }
292
293        let name = &line[start_bracket + 1..end_bracket];
294        let url = &line[start_paren + 1..end_paren];
295
296        // Skip non-http links
297        if !url.starts_with("http://") && !url.starts_with("https://") {
298            return None;
299        }
300
301        // Extract description (after " - " or just after ")")
302        let desc_start = end_paren + 1;
303        let description = if desc_start < line.len() {
304            let desc = &line[desc_start..];
305            let desc = desc.trim_start_matches([' ', '-']);
306            desc.trim().to_string()
307        } else {
308            String::new()
309        };
310
311        // Extract owner from URL
312        let owner = Self::extract_owner_from_url(url);
313
314        let id = format!("awesome:{}#{}", channel_name, name);
315
316        Some(ProfileRef {
317            id,
318            name: name.to_string(),
319            owner,
320            description,
321            url: url.to_string(),
322            stars: None,
323            channel: channel_name.to_string(),
324            metadata: HashMap::new(),
325        })
326    }
327
328    /// Extract owner from GitHub URL
329    fn extract_owner_from_url(url: &str) -> String {
330        // https://github.com/owner/repo -> owner
331        if url.contains("github.com") {
332            let parts: Vec<&str> = url.split('/').collect();
333            if parts.len() >= 4 {
334                return parts[3].to_string();
335            }
336        }
337        "unknown".to_string()
338    }
339
340    /// Convert GitHub URL to raw content URL
341    fn to_raw_url(url: &str) -> String {
342        if url.contains("github.com") && !url.contains("raw.githubusercontent.com") {
343            // https://github.com/user/repo -> raw README
344            let url = url
345                .replace("github.com", "raw.githubusercontent.com")
346                .trim_end_matches('/')
347                .to_string();
348            format!("{}/main/README.md", url)
349        } else {
350            url.to_string()
351        }
352    }
353
354    /// Fetch URL content
355    fn fetch_url(url: &str) -> Result<String> {
356        let output = Command::new("curl")
357            .args(["-sL", url])
358            .output()
359            .map_err(DotAgentError::Io)?;
360
361        if !output.status.success() {
362            return Err(DotAgentError::GitHubApiError {
363                message: format!("Failed to fetch: {}", url),
364            });
365        }
366
367        Ok(String::from_utf8_lossy(&output.stdout).to_string())
368    }
369
370    /// Check if gh CLI is available
371    fn check_gh_available() -> bool {
372        Command::new("gh")
373            .arg("--version")
374            .output()
375            .map(|o| o.status.success())
376            .unwrap_or(false)
377    }
378
379    /// Refresh a channel's cache
380    pub fn refresh_channel(&self, channel_name: &str) -> Result<()> {
381        let channel =
382            self.registry
383                .get(channel_name)
384                .ok_or_else(|| DotAgentError::ChannelNotFound {
385                    name: channel_name.to_string(),
386                })?;
387
388        if let Some(url) = channel.source.url() {
389            let raw_url = Self::to_raw_url(url);
390            let content = Self::fetch_url(&raw_url)?;
391
392            let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
393            std::fs::create_dir_all(&cache_dir)?;
394            std::fs::write(cache_dir.join("content.md"), content)?;
395        }
396
397        Ok(())
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn parse_awesome_line_basic() {
407        let line =
408            "- [dotbot](https://github.com/anishathalye/dotbot) - A tool for managing dotfiles";
409        let result = ChannelManager::parse_awesome_line(line, "test");
410
411        assert!(result.is_some());
412        let profile = result.unwrap();
413        assert_eq!(profile.name, "dotbot");
414        assert_eq!(profile.owner, "anishathalye");
415        assert!(profile.description.contains("managing dotfiles"));
416    }
417
418    #[test]
419    fn parse_awesome_line_star() {
420        let line = "* [stow](https://github.com/aspiers/stow) - Symlink farm manager";
421        let result = ChannelManager::parse_awesome_line(line, "test");
422
423        assert!(result.is_some());
424        let profile = result.unwrap();
425        assert_eq!(profile.name, "stow");
426    }
427
428    #[test]
429    fn parse_awesome_line_no_description() {
430        let line = "- [tool](https://github.com/user/tool)";
431        let result = ChannelManager::parse_awesome_line(line, "test");
432
433        assert!(result.is_some());
434        let profile = result.unwrap();
435        assert_eq!(profile.name, "tool");
436        assert!(profile.description.is_empty());
437    }
438
439    #[test]
440    fn parse_awesome_line_skip_non_http() {
441        let line = "- [Section](#section)";
442        let result = ChannelManager::parse_awesome_line(line, "test");
443        assert!(result.is_none());
444    }
445
446    #[test]
447    fn to_raw_url_github() {
448        let url = "https://github.com/webpro/awesome-dotfiles";
449        let raw = ChannelManager::to_raw_url(url);
450        assert!(raw.contains("raw.githubusercontent.com"));
451        assert!(raw.contains("README.md"));
452    }
453}