Skip to main content

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 serde::{Deserialize, Serialize};
10
11use crate::error::{DotAgentError, Result};
12
13use super::channel_registry::ChannelRegistry;
14use super::types::{Channel, ChannelSource, ChannelType, ProfileRef, SearchOptions};
15
16/// A plugin entry from a Claude Code Plugin Marketplace
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct MarketplacePlugin {
19    /// Plugin name (kebab-case identifier)
20    pub name: String,
21    /// Plugin description
22    pub description: Option<String>,
23    /// Plugin version
24    pub version: Option<String>,
25    /// Plugin source (relative path or git source object)
26    pub source: serde_json::Value,
27    /// Plugin category
28    pub category: Option<String>,
29    /// Plugin keywords/tags
30    pub keywords: Option<Vec<String>>,
31    /// Plugin author
32    pub author: Option<serde_json::Value>,
33    /// LSP servers configuration (inline definition)
34    pub lsp_servers: Option<serde_json::Value>,
35    /// MCP servers configuration (inline definition)
36    pub mcp_servers: Option<serde_json::Value>,
37    /// Hooks configuration (inline definition)
38    pub hooks: Option<serde_json::Value>,
39    /// Commands configuration (inline definition)
40    pub commands: Option<serde_json::Value>,
41    /// Agents configuration (inline definition)
42    pub agents: Option<serde_json::Value>,
43}
44
45impl MarketplacePlugin {
46    /// Create from JSON value
47    pub fn from_json(value: &serde_json::Value) -> Self {
48        Self {
49            name: value
50                .get("name")
51                .and_then(|v| v.as_str())
52                .unwrap_or_default()
53                .to_string(),
54            description: value
55                .get("description")
56                .and_then(|v| v.as_str())
57                .map(|s| s.to_string()),
58            version: value
59                .get("version")
60                .and_then(|v| v.as_str())
61                .map(|s| s.to_string()),
62            source: value
63                .get("source")
64                .cloned()
65                .unwrap_or(serde_json::Value::Null),
66            category: value
67                .get("category")
68                .and_then(|v| v.as_str())
69                .map(|s| s.to_string()),
70            keywords: value.get("keywords").and_then(|v| {
71                v.as_array().map(|arr| {
72                    arr.iter()
73                        .filter_map(|k| k.as_str().map(|s| s.to_string()))
74                        .collect()
75                })
76            }),
77            author: value.get("author").cloned(),
78            lsp_servers: value.get("lspServers").cloned(),
79            mcp_servers: value.get("mcpServers").cloned(),
80            hooks: value.get("hooks").cloned(),
81            commands: value.get("commands").cloned(),
82            agents: value.get("agents").cloned(),
83        }
84    }
85
86    /// Get the source as a relative path (if it's a string)
87    pub fn source_path(&self) -> Option<&str> {
88        self.source.as_str()
89    }
90
91    /// Get the source as a git repo (if it's an object with "source": "github")
92    pub fn source_github_repo(&self) -> Option<&str> {
93        self.source.get("repo").and_then(|v| v.as_str())
94    }
95
96    /// Get the source as a URL (if it's an object with "source": "url")
97    pub fn source_url(&self) -> Option<&str> {
98        if self.source.get("source").and_then(|v| v.as_str()) == Some("url") {
99            self.source.get("url").and_then(|v| v.as_str())
100        } else {
101            None
102        }
103    }
104
105    /// Check if plugin has inline configurations (strict: false pattern)
106    pub fn has_inline_config(&self) -> bool {
107        self.lsp_servers.is_some()
108            || self.mcp_servers.is_some()
109            || self.hooks.is_some()
110            || self.commands.is_some()
111            || self.agents.is_some()
112    }
113
114    /// Write inline configurations to the target directory as separate files
115    pub fn write_config_files(&self, target_dir: &std::path::Path) -> Result<Vec<String>> {
116        use std::fs;
117
118        let mut written_files = Vec::new();
119
120        // Write .lsp.json if lspServers is defined
121        if let Some(lsp) = &self.lsp_servers {
122            let lsp_path = target_dir.join(".lsp.json");
123            let content =
124                serde_json::to_string_pretty(lsp).map_err(|e| DotAgentError::GitHubApiError {
125                    message: format!("Failed to serialize lspServers: {}", e),
126                })?;
127            fs::write(&lsp_path, content)?;
128            written_files.push(".lsp.json".to_string());
129        }
130
131        // Write .mcp.json if mcpServers is defined
132        if let Some(mcp) = &self.mcp_servers {
133            let mcp_path = target_dir.join(".mcp.json");
134            let content =
135                serde_json::to_string_pretty(mcp).map_err(|e| DotAgentError::GitHubApiError {
136                    message: format!("Failed to serialize mcpServers: {}", e),
137                })?;
138            fs::write(&mcp_path, content)?;
139            written_files.push(".mcp.json".to_string());
140        }
141
142        // Write hooks.json if hooks is defined
143        if let Some(hooks) = &self.hooks {
144            let hooks_path = target_dir.join("hooks.json");
145            let content =
146                serde_json::to_string_pretty(hooks).map_err(|e| DotAgentError::GitHubApiError {
147                    message: format!("Failed to serialize hooks: {}", e),
148                })?;
149            fs::write(&hooks_path, content)?;
150            written_files.push("hooks.json".to_string());
151        }
152
153        // Write commands.json if commands is defined
154        if let Some(commands) = &self.commands {
155            let commands_path = target_dir.join("commands.json");
156            let content = serde_json::to_string_pretty(commands).map_err(|e| {
157                DotAgentError::GitHubApiError {
158                    message: format!("Failed to serialize commands: {}", e),
159                }
160            })?;
161            fs::write(&commands_path, content)?;
162            written_files.push("commands.json".to_string());
163        }
164
165        // Write agents.json if agents is defined
166        if let Some(agents) = &self.agents {
167            let agents_path = target_dir.join("agents.json");
168            let content = serde_json::to_string_pretty(agents).map_err(|e| {
169                DotAgentError::GitHubApiError {
170                    message: format!("Failed to serialize agents: {}", e),
171                }
172            })?;
173            fs::write(&agents_path, content)?;
174            written_files.push("agents.json".to_string());
175        }
176
177        Ok(written_files)
178    }
179}
180
181/// Channel manager for search operations
182pub struct ChannelManager {
183    base_dir: PathBuf,
184    registry: ChannelRegistry,
185}
186
187impl ChannelManager {
188    /// Create a new channel manager
189    pub fn new(base_dir: PathBuf) -> Result<Self> {
190        let registry = ChannelRegistry::load(&base_dir)?;
191        Ok(Self { base_dir, registry })
192    }
193
194    /// Create with a specific registry
195    pub fn with_registry(base_dir: PathBuf, registry: ChannelRegistry) -> Self {
196        Self { base_dir, registry }
197    }
198
199    /// Get the registry
200    pub fn registry(&self) -> &ChannelRegistry {
201        &self.registry
202    }
203
204    /// Get mutable registry
205    pub fn registry_mut(&mut self) -> &mut ChannelRegistry {
206        &mut self.registry
207    }
208
209    /// Save registry
210    pub fn save(&self) -> Result<()> {
211        self.registry.save(&self.base_dir)
212    }
213
214    /// Search across all enabled searchable channels
215    pub fn search(&self, query: &str, options: &SearchOptions) -> Result<Vec<ProfileRef>> {
216        let mut results = Vec::new();
217
218        // Get channels to search
219        let channels: Vec<&Channel> = if options.channels.is_empty() {
220            self.registry.list_searchable()
221        } else {
222            self.registry
223                .list_enabled()
224                .into_iter()
225                .filter(|c| options.channels.contains(&c.name))
226                .collect()
227        };
228
229        for channel in channels {
230            match self.search_channel(channel, query, options) {
231                Ok(refs) => results.extend(refs),
232                Err(e) => {
233                    eprintln!("Warning: {} search failed: {}", channel.name, e);
234                }
235            }
236        }
237
238        // Sort by stars (descending)
239        results.sort_by(|a, b| b.stars.cmp(&a.stars));
240
241        // Apply limit
242        if options.limit > 0 {
243            results.truncate(options.limit);
244        }
245
246        Ok(results)
247    }
248
249    /// Search a specific channel
250    fn search_channel(
251        &self,
252        channel: &Channel,
253        query: &str,
254        options: &SearchOptions,
255    ) -> Result<Vec<ProfileRef>> {
256        match channel.channel_type {
257            ChannelType::GitHubGlobal => self.search_github(channel, query, options),
258            ChannelType::AwesomeList => self.search_awesome_list(channel, query, options),
259            ChannelType::Marketplace => self.search_marketplace(channel, query, options),
260            ChannelType::Hub | ChannelType::Direct => {
261                // Not searchable
262                Ok(Vec::new())
263            }
264        }
265    }
266
267    /// Search GitHub globally
268    fn search_github(
269        &self,
270        channel: &Channel,
271        query: &str,
272        options: &SearchOptions,
273    ) -> Result<Vec<ProfileRef>> {
274        // Check if gh CLI is available
275        if !Self::check_gh_available() {
276            return Err(DotAgentError::GitHubCliNotFound);
277        }
278
279        let mut args = vec!["search".to_string(), "repos".to_string()];
280
281        // Build query
282        let search_query = if options.keywords.is_empty() {
283            query.to_string()
284        } else {
285            format!("{} {}", query, options.keywords.join(" "))
286        };
287
288        if !search_query.is_empty() {
289            args.push(search_query);
290        }
291
292        // Topic filter
293        if let Some(topic) = &options.topic {
294            args.push("--topic".to_string());
295            args.push(topic.clone());
296        }
297
298        // Sort order
299        args.push("--sort".to_string());
300        args.push(options.sort.clone().unwrap_or_else(|| "stars".to_string()));
301
302        // Limit (per channel)
303        let limit = if options.limit > 0 { options.limit } else { 10 };
304        args.push("--limit".to_string());
305        args.push(limit.to_string());
306
307        // Stars filter
308        if let Some(min) = options.min_stars {
309            args.push("--stars".to_string());
310            args.push(format!(">={}", min));
311        }
312
313        // JSON output
314        args.push("--json".to_string());
315        args.push("name,owner,description,url,stargazersCount".to_string());
316
317        let output = Command::new("gh").args(&args).output().map_err(|e| {
318            if e.kind() == std::io::ErrorKind::NotFound {
319                DotAgentError::GitHubCliNotFound
320            } else {
321                DotAgentError::Io(e)
322            }
323        })?;
324
325        if !output.status.success() {
326            let stderr = String::from_utf8_lossy(&output.stderr);
327            return Err(DotAgentError::GitHubApiError {
328                message: stderr.to_string(),
329            });
330        }
331
332        let json: serde_json::Value =
333            serde_json::from_slice(&output.stdout).unwrap_or(serde_json::Value::Array(vec![]));
334
335        let repos = json
336            .as_array()
337            .ok_or_else(|| DotAgentError::GitHubApiError {
338                message: "Invalid JSON response".to_string(),
339            })?;
340
341        let mut results = Vec::new();
342
343        for repo in repos {
344            let name = repo["name"].as_str().unwrap_or("").to_string();
345            let owner = repo["owner"]["login"].as_str().unwrap_or("").to_string();
346            let description = repo["description"]
347                .as_str()
348                .unwrap_or("No description")
349                .to_string();
350            let url = repo["url"].as_str().unwrap_or("").to_string();
351            let stars = repo["stargazersCount"].as_u64();
352
353            let id = format!("github:{}/{}", owner, name);
354
355            results.push(ProfileRef {
356                id,
357                name,
358                owner,
359                description,
360                url,
361                stars,
362                channel: channel.name.clone(),
363                metadata: HashMap::new(),
364            });
365        }
366
367        Ok(results)
368    }
369
370    /// Search an Awesome List
371    fn search_awesome_list(
372        &self,
373        channel: &Channel,
374        query: &str,
375        options: &SearchOptions,
376    ) -> Result<Vec<ProfileRef>> {
377        let url = match channel.source.url() {
378            Some(u) => u,
379            None => return Ok(Vec::new()),
380        };
381
382        // Get cached content or fetch
383        let content = self.fetch_awesome_list(url, &channel.name)?;
384
385        // Parse and search
386        let query_lower = query.to_lowercase();
387        let keywords: Vec<String> = options.keywords.iter().map(|k| k.to_lowercase()).collect();
388
389        let mut results = Vec::new();
390
391        for line in content.lines() {
392            if let Some(profile_ref) = Self::parse_awesome_line(line, &channel.name) {
393                // Check if matches query
394                let text = format!(
395                    "{} {} {}",
396                    profile_ref.name, profile_ref.owner, profile_ref.description
397                )
398                .to_lowercase();
399
400                let matches_query = query.is_empty() || text.contains(&query_lower);
401                let matches_keywords =
402                    keywords.is_empty() || keywords.iter().all(|k| text.contains(k));
403
404                if matches_query && matches_keywords {
405                    results.push(profile_ref);
406                }
407            }
408        }
409
410        // Apply limit
411        if options.limit > 0 {
412            results.truncate(options.limit);
413        }
414
415        Ok(results)
416    }
417
418    /// Fetch Awesome List content (with caching)
419    fn fetch_awesome_list(&self, url: &str, channel_name: &str) -> Result<String> {
420        let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
421        let cache_file = cache_dir.join("content.md");
422
423        // Check cache
424        if cache_file.exists() {
425            if let Ok(content) = std::fs::read_to_string(&cache_file) {
426                return Ok(content);
427            }
428        }
429
430        // Fetch from URL, try main first, then master
431        let content = if let Some(c) = Self::fetch_url(&Self::to_raw_url(url, "main"))? {
432            c
433        } else if let Some(c) = Self::fetch_url(&Self::to_raw_url(url, "master"))? {
434            c
435        } else {
436            return Err(DotAgentError::GitHubApiError {
437                message: format!(
438                    "Failed to fetch README from: {} (tried main and master)",
439                    url
440                ),
441            });
442        };
443
444        // Cache it
445        std::fs::create_dir_all(&cache_dir)?;
446        std::fs::write(&cache_file, &content)?;
447
448        Ok(content)
449    }
450
451    /// Parse a line from Awesome List markdown
452    fn parse_awesome_line(line: &str, channel_name: &str) -> Option<ProfileRef> {
453        // Format: - [name](url) - description
454        // or: * [name](url) - description
455        let line = line.trim();
456
457        if !line.starts_with('-') && !line.starts_with('*') {
458            return None;
459        }
460
461        // Find [name](url)
462        let start_bracket = line.find('[')?;
463        let end_bracket = line.find(']')?;
464        let start_paren = line.find('(')?;
465        let end_paren = line.find(')')?;
466
467        if end_bracket >= start_paren || start_paren >= end_paren {
468            return None;
469        }
470
471        let name = &line[start_bracket + 1..end_bracket];
472        let url = &line[start_paren + 1..end_paren];
473
474        // Skip non-http links
475        if !url.starts_with("http://") && !url.starts_with("https://") {
476            return None;
477        }
478
479        // Extract description (after " - " or just after ")")
480        let desc_start = end_paren + 1;
481        let description = if desc_start < line.len() {
482            let desc = &line[desc_start..];
483            let desc = desc.trim_start_matches([' ', '-']);
484            desc.trim().to_string()
485        } else {
486            String::new()
487        };
488
489        // Extract owner from URL
490        let owner = Self::extract_owner_from_url(url);
491
492        let id = format!("awesome:{}#{}", channel_name, name);
493
494        Some(ProfileRef {
495            id,
496            name: name.to_string(),
497            owner,
498            description,
499            url: url.to_string(),
500            stars: None,
501            channel: channel_name.to_string(),
502            metadata: HashMap::new(),
503        })
504    }
505
506    /// Extract owner from GitHub URL
507    fn extract_owner_from_url(url: &str) -> String {
508        // https://github.com/owner/repo -> owner
509        if url.contains("github.com") {
510            let parts: Vec<&str> = url.split('/').collect();
511            if parts.len() >= 4 {
512                return parts[3].to_string();
513            }
514        }
515        "unknown".to_string()
516    }
517
518    /// Convert GitHub URL to raw content URL with specified branch
519    fn to_raw_url(url: &str, branch: &str) -> String {
520        if url.contains("github.com") && !url.contains("raw.githubusercontent.com") {
521            // https://github.com/user/repo -> raw README
522            let url = url
523                .replace("github.com", "raw.githubusercontent.com")
524                .trim_end_matches('/')
525                .to_string();
526            format!("{}/{}/README.md", url, branch)
527        } else {
528            url.to_string()
529        }
530    }
531
532    /// Normalize repo string to owner/repo format
533    /// Accepts: "owner/repo", "https://github.com/owner/repo", "github.com/owner/repo"
534    fn normalize_repo(repo: &str) -> String {
535        let repo = repo.trim_end_matches('/');
536
537        // Already in owner/repo format
538        if !repo.contains("://") && !repo.contains("github.com") {
539            return repo.to_string();
540        }
541
542        // Extract owner/repo from URL
543        // https://github.com/owner/repo -> owner/repo
544        if let Some(pos) = repo.find("github.com/") {
545            let after = &repo[pos + 11..]; // skip "github.com/"
546            return after.to_string();
547        }
548
549        repo.to_string()
550    }
551
552    /// Fetch URL content, returns None for 404
553    fn fetch_url(url: &str) -> Result<Option<String>> {
554        let output = Command::new("curl")
555            .args(["-sL", "-w", "%{http_code}", "-o", "-", url])
556            .output()
557            .map_err(DotAgentError::Io)?;
558
559        let stdout = String::from_utf8_lossy(&output.stdout);
560
561        // Extract HTTP status code (last 3 chars)
562        if stdout.len() >= 3 {
563            let (content, status) = stdout.split_at(stdout.len() - 3);
564            if status == "404" {
565                return Ok(None);
566            }
567            if status.starts_with('2') {
568                return Ok(Some(content.to_string()));
569            }
570        }
571
572        // Fallback: check if output looks like an error
573        if !output.status.success() || stdout.contains("404") {
574            return Ok(None);
575        }
576
577        Ok(Some(stdout.to_string()))
578    }
579
580    /// Check if gh CLI is available
581    fn check_gh_available() -> bool {
582        Command::new("gh")
583            .arg("--version")
584            .output()
585            .map(|o| o.status.success())
586            .unwrap_or(false)
587    }
588
589    /// Refresh a channel's cache
590    pub fn refresh_channel(&self, channel_name: &str) -> Result<()> {
591        let channel =
592            self.registry
593                .get(channel_name)
594                .ok_or_else(|| DotAgentError::ChannelNotFound {
595                    name: channel_name.to_string(),
596                })?;
597
598        match &channel.source {
599            ChannelSource::Marketplace { repo } => {
600                self.fetch_marketplace_catalog(repo, channel_name)?;
601            }
602            _ => {
603                if let Some(url) = channel.source.url() {
604                    // Try main first, then master
605                    let content = if let Some(c) = Self::fetch_url(&Self::to_raw_url(url, "main"))?
606                    {
607                        c
608                    } else if let Some(c) = Self::fetch_url(&Self::to_raw_url(url, "master"))? {
609                        c
610                    } else {
611                        return Err(DotAgentError::GitHubApiError {
612                            message: format!(
613                                "Failed to fetch README from: {} (tried main and master)",
614                                url
615                            ),
616                        });
617                    };
618
619                    let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
620                    std::fs::create_dir_all(&cache_dir)?;
621                    std::fs::write(cache_dir.join("content.md"), content)?;
622                }
623            }
624        }
625
626        Ok(())
627    }
628
629    /// Fetch marketplace catalog from GitHub repository
630    ///
631    /// Downloads `.claude-plugin/marketplace.json` from the repository
632    /// and caches it locally.
633    fn fetch_marketplace_catalog(&self, repo: &str, channel_name: &str) -> Result<()> {
634        // Normalize repo: extract owner/repo from full URL if needed
635        let repo = Self::normalize_repo(repo);
636
637        // Try main first, then master
638        let content = if let Some(c) = Self::fetch_url(&format!(
639            "https://raw.githubusercontent.com/{}/main/.claude-plugin/marketplace.json",
640            repo
641        ))? {
642            c
643        } else if let Some(c) = Self::fetch_url(&format!(
644            "https://raw.githubusercontent.com/{}/master/.claude-plugin/marketplace.json",
645            repo
646        ))? {
647            c
648        } else {
649            return Err(DotAgentError::GitHubApiError {
650                message: format!(
651                    "Failed to fetch marketplace.json from: {} (tried main and master)",
652                    repo
653                ),
654            });
655        };
656
657        // Validate JSON structure
658        let data: serde_json::Value =
659            serde_json::from_str(&content).map_err(|e| DotAgentError::GitHubApiError {
660                message: format!("Invalid marketplace.json: {}", e),
661            })?;
662
663        // Check required fields
664        if data.get("name").is_none() || data.get("plugins").is_none() {
665            return Err(DotAgentError::GitHubApiError {
666                message: "marketplace.json missing required fields (name, plugins)".to_string(),
667            });
668        }
669
670        // Cache the catalog
671        let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
672        std::fs::create_dir_all(&cache_dir)?;
673        std::fs::write(cache_dir.join("marketplace.json"), content)?;
674
675        Ok(())
676    }
677
678    /// Get a plugin entry from marketplace catalog
679    pub fn get_marketplace_plugin(
680        &self,
681        channel_name: &str,
682        plugin_name: &str,
683    ) -> Result<Option<MarketplacePlugin>> {
684        let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
685        let cache_file = cache_dir.join("marketplace.json");
686
687        if !cache_file.exists() {
688            return Ok(None);
689        }
690
691        let content = std::fs::read_to_string(&cache_file)?;
692        let data: serde_json::Value =
693            serde_json::from_str(&content).map_err(|e| DotAgentError::GitHubApiError {
694                message: format!("Invalid marketplace.json: {}", e),
695            })?;
696
697        let plugins = data
698            .get("plugins")
699            .and_then(|p| p.as_array())
700            .ok_or_else(|| DotAgentError::GitHubApiError {
701                message: "marketplace.json has no plugins array".to_string(),
702            })?;
703
704        for plugin in plugins {
705            let name = plugin.get("name").and_then(|n| n.as_str()).unwrap_or("");
706            if name == plugin_name {
707                return Ok(Some(MarketplacePlugin::from_json(plugin)));
708            }
709        }
710
711        Ok(None)
712    }
713
714    /// Search a Marketplace channel
715    fn search_marketplace(
716        &self,
717        channel: &Channel,
718        query: &str,
719        options: &SearchOptions,
720    ) -> Result<Vec<ProfileRef>> {
721        let repo = match &channel.source {
722            ChannelSource::Marketplace { repo } => repo,
723            _ => return Ok(Vec::new()),
724        };
725
726        let query_lower = query.to_lowercase();
727        let mut results = Vec::new();
728
729        // Check if marketplace content is cached
730        let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, &channel.name);
731        let cache_file = cache_dir.join("marketplace.json");
732
733        // Auto-fetch if cache doesn't exist
734        if !cache_file.exists() {
735            self.fetch_marketplace_catalog(repo, &channel.name)?;
736        }
737
738        // Parse cached marketplace data
739        if let Ok(content) = std::fs::read_to_string(&cache_file) {
740            if let Ok(data) = serde_json::from_str::<serde_json::Value>(&content) {
741                if let Some(plugins) = data.get("plugins").and_then(|p| p.as_array()) {
742                    for plugin in plugins {
743                        let name = plugin
744                            .get("name")
745                            .and_then(|n| n.as_str())
746                            .unwrap_or_default();
747                        let description = plugin
748                            .get("description")
749                            .and_then(|d| d.as_str())
750                            .unwrap_or_default();
751                        let version = plugin
752                            .get("version")
753                            .and_then(|v| v.as_str())
754                            .unwrap_or("unknown");
755                        let category = plugin
756                            .get("category")
757                            .and_then(|c| c.as_str())
758                            .unwrap_or("");
759
760                        // Filter by query (search in name, description, category)
761                        let text = format!("{} {} {}", name, description, category).to_lowercase();
762                        if query.is_empty() || text.contains(&query_lower) {
763                            let mut metadata = HashMap::new();
764                            metadata.insert("version".to_string(), version.to_string());
765                            if !category.is_empty() {
766                                metadata.insert("category".to_string(), category.to_string());
767                            }
768
769                            results.push(ProfileRef {
770                                id: format!("marketplace:{}@{}", name, channel.name),
771                                name: name.to_string(),
772                                owner: repo.split('/').next().unwrap_or("unknown").to_string(),
773                                description: description.to_string(),
774                                url: format!("https://github.com/{}", repo),
775                                stars: None,
776                                channel: channel.name.clone(),
777                                metadata,
778                            });
779                        }
780                    }
781                }
782            }
783        }
784
785        // Apply limit
786        if options.limit > 0 {
787            results.truncate(options.limit);
788        }
789
790        Ok(results)
791    }
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797
798    #[test]
799    fn parse_awesome_line_basic() {
800        let line =
801            "- [dotbot](https://github.com/anishathalye/dotbot) - A tool for managing dotfiles";
802        let result = ChannelManager::parse_awesome_line(line, "test");
803
804        assert!(result.is_some());
805        let profile = result.unwrap();
806        assert_eq!(profile.name, "dotbot");
807        assert_eq!(profile.owner, "anishathalye");
808        assert!(profile.description.contains("managing dotfiles"));
809    }
810
811    #[test]
812    fn parse_awesome_line_star() {
813        let line = "* [stow](https://github.com/aspiers/stow) - Symlink farm manager";
814        let result = ChannelManager::parse_awesome_line(line, "test");
815
816        assert!(result.is_some());
817        let profile = result.unwrap();
818        assert_eq!(profile.name, "stow");
819    }
820
821    #[test]
822    fn parse_awesome_line_no_description() {
823        let line = "- [tool](https://github.com/user/tool)";
824        let result = ChannelManager::parse_awesome_line(line, "test");
825
826        assert!(result.is_some());
827        let profile = result.unwrap();
828        assert_eq!(profile.name, "tool");
829        assert!(profile.description.is_empty());
830    }
831
832    #[test]
833    fn parse_awesome_line_skip_non_http() {
834        let line = "- [Section](#section)";
835        let result = ChannelManager::parse_awesome_line(line, "test");
836        assert!(result.is_none());
837    }
838
839    #[test]
840    fn to_raw_url_github() {
841        let url = "https://github.com/webpro/awesome-dotfiles";
842        let raw = ChannelManager::to_raw_url(url, "main");
843        assert!(raw.contains("raw.githubusercontent.com"));
844        assert!(raw.contains("README.md"));
845        assert!(raw.contains("/main/"));
846
847        let raw_master = ChannelManager::to_raw_url(url, "master");
848        assert!(raw_master.contains("/master/"));
849    }
850}