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.get("source").cloned().unwrap_or(serde_json::Value::Null),
63            category: value
64                .get("category")
65                .and_then(|v| v.as_str())
66                .map(|s| s.to_string()),
67            keywords: value.get("keywords").and_then(|v| {
68                v.as_array().map(|arr| {
69                    arr.iter()
70                        .filter_map(|k| k.as_str().map(|s| s.to_string()))
71                        .collect()
72                })
73            }),
74            author: value.get("author").cloned(),
75            lsp_servers: value.get("lspServers").cloned(),
76            mcp_servers: value.get("mcpServers").cloned(),
77            hooks: value.get("hooks").cloned(),
78            commands: value.get("commands").cloned(),
79            agents: value.get("agents").cloned(),
80        }
81    }
82
83    /// Get the source as a relative path (if it's a string)
84    pub fn source_path(&self) -> Option<&str> {
85        self.source.as_str()
86    }
87
88    /// Get the source as a git repo (if it's an object with "source": "github")
89    pub fn source_github_repo(&self) -> Option<&str> {
90        self.source.get("repo").and_then(|v| v.as_str())
91    }
92
93    /// Get the source as a URL (if it's an object with "source": "url")
94    pub fn source_url(&self) -> Option<&str> {
95        if self.source.get("source").and_then(|v| v.as_str()) == Some("url") {
96            self.source.get("url").and_then(|v| v.as_str())
97        } else {
98            None
99        }
100    }
101
102    /// Check if plugin has inline configurations (strict: false pattern)
103    pub fn has_inline_config(&self) -> bool {
104        self.lsp_servers.is_some()
105            || self.mcp_servers.is_some()
106            || self.hooks.is_some()
107            || self.commands.is_some()
108            || self.agents.is_some()
109    }
110
111    /// Write inline configurations to the target directory as separate files
112    pub fn write_config_files(&self, target_dir: &std::path::Path) -> Result<Vec<String>> {
113        use std::fs;
114
115        let mut written_files = Vec::new();
116
117        // Write .lsp.json if lspServers is defined
118        if let Some(lsp) = &self.lsp_servers {
119            let lsp_path = target_dir.join(".lsp.json");
120            let content = serde_json::to_string_pretty(lsp).map_err(|e| {
121                DotAgentError::GitHubApiError {
122                    message: format!("Failed to serialize lspServers: {}", e),
123                }
124            })?;
125            fs::write(&lsp_path, content)?;
126            written_files.push(".lsp.json".to_string());
127        }
128
129        // Write .mcp.json if mcpServers is defined
130        if let Some(mcp) = &self.mcp_servers {
131            let mcp_path = target_dir.join(".mcp.json");
132            let content = serde_json::to_string_pretty(mcp).map_err(|e| {
133                DotAgentError::GitHubApiError {
134                    message: format!("Failed to serialize mcpServers: {}", e),
135                }
136            })?;
137            fs::write(&mcp_path, content)?;
138            written_files.push(".mcp.json".to_string());
139        }
140
141        // Write hooks.json if hooks is defined
142        if let Some(hooks) = &self.hooks {
143            let hooks_path = target_dir.join("hooks.json");
144            let content = serde_json::to_string_pretty(hooks).map_err(|e| {
145                DotAgentError::GitHubApiError {
146                    message: format!("Failed to serialize hooks: {}", e),
147                }
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!("Failed to fetch README from: {} (tried main and master)", url),
438            });
439        };
440
441        // Cache it
442        std::fs::create_dir_all(&cache_dir)?;
443        std::fs::write(&cache_file, &content)?;
444
445        Ok(content)
446    }
447
448    /// Parse a line from Awesome List markdown
449    fn parse_awesome_line(line: &str, channel_name: &str) -> Option<ProfileRef> {
450        // Format: - [name](url) - description
451        // or: * [name](url) - description
452        let line = line.trim();
453
454        if !line.starts_with('-') && !line.starts_with('*') {
455            return None;
456        }
457
458        // Find [name](url)
459        let start_bracket = line.find('[')?;
460        let end_bracket = line.find(']')?;
461        let start_paren = line.find('(')?;
462        let end_paren = line.find(')')?;
463
464        if end_bracket >= start_paren || start_paren >= end_paren {
465            return None;
466        }
467
468        let name = &line[start_bracket + 1..end_bracket];
469        let url = &line[start_paren + 1..end_paren];
470
471        // Skip non-http links
472        if !url.starts_with("http://") && !url.starts_with("https://") {
473            return None;
474        }
475
476        // Extract description (after " - " or just after ")")
477        let desc_start = end_paren + 1;
478        let description = if desc_start < line.len() {
479            let desc = &line[desc_start..];
480            let desc = desc.trim_start_matches([' ', '-']);
481            desc.trim().to_string()
482        } else {
483            String::new()
484        };
485
486        // Extract owner from URL
487        let owner = Self::extract_owner_from_url(url);
488
489        let id = format!("awesome:{}#{}", channel_name, name);
490
491        Some(ProfileRef {
492            id,
493            name: name.to_string(),
494            owner,
495            description,
496            url: url.to_string(),
497            stars: None,
498            channel: channel_name.to_string(),
499            metadata: HashMap::new(),
500        })
501    }
502
503    /// Extract owner from GitHub URL
504    fn extract_owner_from_url(url: &str) -> String {
505        // https://github.com/owner/repo -> owner
506        if url.contains("github.com") {
507            let parts: Vec<&str> = url.split('/').collect();
508            if parts.len() >= 4 {
509                return parts[3].to_string();
510            }
511        }
512        "unknown".to_string()
513    }
514
515    /// Convert GitHub URL to raw content URL with specified branch
516    fn to_raw_url(url: &str, branch: &str) -> String {
517        if url.contains("github.com") && !url.contains("raw.githubusercontent.com") {
518            // https://github.com/user/repo -> raw README
519            let url = url
520                .replace("github.com", "raw.githubusercontent.com")
521                .trim_end_matches('/')
522                .to_string();
523            format!("{}/{}/README.md", url, branch)
524        } else {
525            url.to_string()
526        }
527    }
528
529    /// Normalize repo string to owner/repo format
530    /// Accepts: "owner/repo", "https://github.com/owner/repo", "github.com/owner/repo"
531    fn normalize_repo(repo: &str) -> String {
532        let repo = repo.trim_end_matches('/');
533
534        // Already in owner/repo format
535        if !repo.contains("://") && !repo.contains("github.com") {
536            return repo.to_string();
537        }
538
539        // Extract owner/repo from URL
540        // https://github.com/owner/repo -> owner/repo
541        if let Some(pos) = repo.find("github.com/") {
542            let after = &repo[pos + 11..]; // skip "github.com/"
543            return after.to_string();
544        }
545
546        repo.to_string()
547    }
548
549    /// Fetch URL content, returns None for 404
550    fn fetch_url(url: &str) -> Result<Option<String>> {
551        let output = Command::new("curl")
552            .args(["-sL", "-w", "%{http_code}", "-o", "-", url])
553            .output()
554            .map_err(DotAgentError::Io)?;
555
556        let stdout = String::from_utf8_lossy(&output.stdout);
557
558        // Extract HTTP status code (last 3 chars)
559        if stdout.len() >= 3 {
560            let (content, status) = stdout.split_at(stdout.len() - 3);
561            if status == "404" {
562                return Ok(None);
563            }
564            if status.starts_with('2') {
565                return Ok(Some(content.to_string()));
566            }
567        }
568
569        // Fallback: check if output looks like an error
570        if !output.status.success() || stdout.contains("404") {
571            return Ok(None);
572        }
573
574        Ok(Some(stdout.to_string()))
575    }
576
577    /// Check if gh CLI is available
578    fn check_gh_available() -> bool {
579        Command::new("gh")
580            .arg("--version")
581            .output()
582            .map(|o| o.status.success())
583            .unwrap_or(false)
584    }
585
586    /// Refresh a channel's cache
587    pub fn refresh_channel(&self, channel_name: &str) -> Result<()> {
588        let channel =
589            self.registry
590                .get(channel_name)
591                .ok_or_else(|| DotAgentError::ChannelNotFound {
592                    name: channel_name.to_string(),
593                })?;
594
595        match &channel.source {
596            ChannelSource::Marketplace { repo } => {
597                self.fetch_marketplace_catalog(repo, channel_name)?;
598            }
599            _ => {
600                if let Some(url) = channel.source.url() {
601                    // Try main first, then master
602                    let content =
603                        if let Some(c) = Self::fetch_url(&Self::to_raw_url(url, "main"))? {
604                            c
605                        } else if let Some(c) = Self::fetch_url(&Self::to_raw_url(url, "master"))? {
606                            c
607                        } else {
608                            return Err(DotAgentError::GitHubApiError {
609                                message: format!(
610                                    "Failed to fetch README from: {} (tried main and master)",
611                                    url
612                                ),
613                            });
614                        };
615
616                    let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
617                    std::fs::create_dir_all(&cache_dir)?;
618                    std::fs::write(cache_dir.join("content.md"), content)?;
619                }
620            }
621        }
622
623        Ok(())
624    }
625
626    /// Fetch marketplace catalog from GitHub repository
627    ///
628    /// Downloads `.claude-plugin/marketplace.json` from the repository
629    /// and caches it locally.
630    fn fetch_marketplace_catalog(&self, repo: &str, channel_name: &str) -> Result<()> {
631        // Normalize repo: extract owner/repo from full URL if needed
632        let repo = Self::normalize_repo(repo);
633
634        // Try main first, then master
635        let content = if let Some(c) = Self::fetch_url(&format!(
636            "https://raw.githubusercontent.com/{}/main/.claude-plugin/marketplace.json",
637            repo
638        ))? {
639            c
640        } else if let Some(c) = Self::fetch_url(&format!(
641            "https://raw.githubusercontent.com/{}/master/.claude-plugin/marketplace.json",
642            repo
643        ))? {
644            c
645        } else {
646            return Err(DotAgentError::GitHubApiError {
647                message: format!(
648                    "Failed to fetch marketplace.json from: {} (tried main and master)",
649                    repo
650                ),
651            });
652        };
653
654        // Validate JSON structure
655        let data: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
656            DotAgentError::GitHubApiError {
657                message: format!("Invalid marketplace.json: {}", e),
658            }
659        })?;
660
661        // Check required fields
662        if data.get("name").is_none() || data.get("plugins").is_none() {
663            return Err(DotAgentError::GitHubApiError {
664                message: "marketplace.json missing required fields (name, plugins)".to_string(),
665            });
666        }
667
668        // Cache the catalog
669        let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
670        std::fs::create_dir_all(&cache_dir)?;
671        std::fs::write(cache_dir.join("marketplace.json"), content)?;
672
673        Ok(())
674    }
675
676    /// Get a plugin entry from marketplace catalog
677    pub fn get_marketplace_plugin(
678        &self,
679        channel_name: &str,
680        plugin_name: &str,
681    ) -> Result<Option<MarketplacePlugin>> {
682        let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, channel_name);
683        let cache_file = cache_dir.join("marketplace.json");
684
685        if !cache_file.exists() {
686            return Ok(None);
687        }
688
689        let content = std::fs::read_to_string(&cache_file)?;
690        let data: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
691            DotAgentError::GitHubApiError {
692                message: format!("Invalid marketplace.json: {}", e),
693            }
694        })?;
695
696        let plugins = data
697            .get("plugins")
698            .and_then(|p| p.as_array())
699            .ok_or_else(|| DotAgentError::GitHubApiError {
700                message: "marketplace.json has no plugins array".to_string(),
701            })?;
702
703        for plugin in plugins {
704            let name = plugin.get("name").and_then(|n| n.as_str()).unwrap_or("");
705            if name == plugin_name {
706                return Ok(Some(MarketplacePlugin::from_json(plugin)));
707            }
708        }
709
710        Ok(None)
711    }
712
713    /// Search a Marketplace channel
714    fn search_marketplace(
715        &self,
716        channel: &Channel,
717        query: &str,
718        options: &SearchOptions,
719    ) -> Result<Vec<ProfileRef>> {
720        let repo = match &channel.source {
721            ChannelSource::Marketplace { repo } => repo,
722            _ => return Ok(Vec::new()),
723        };
724
725        let query_lower = query.to_lowercase();
726        let mut results = Vec::new();
727
728        // Check if marketplace content is cached
729        let cache_dir = ChannelRegistry::cache_dir(&self.base_dir, &channel.name);
730        let cache_file = cache_dir.join("marketplace.json");
731
732        // Auto-fetch if cache doesn't exist
733        if !cache_file.exists() {
734            self.fetch_marketplace_catalog(repo, &channel.name)?;
735        }
736
737        // Parse cached marketplace data
738        if let Ok(content) = std::fs::read_to_string(&cache_file) {
739            if let Ok(data) = serde_json::from_str::<serde_json::Value>(&content) {
740                if let Some(plugins) = data.get("plugins").and_then(|p| p.as_array()) {
741                    for plugin in plugins {
742                        let name = plugin
743                            .get("name")
744                            .and_then(|n| n.as_str())
745                            .unwrap_or_default();
746                        let description = plugin
747                            .get("description")
748                            .and_then(|d| d.as_str())
749                            .unwrap_or_default();
750                        let version = plugin
751                            .get("version")
752                            .and_then(|v| v.as_str())
753                            .unwrap_or("unknown");
754                        let category = plugin
755                            .get("category")
756                            .and_then(|c| c.as_str())
757                            .unwrap_or("");
758
759                        // Filter by query (search in name, description, category)
760                        let text = format!("{} {} {}", name, description, category).to_lowercase();
761                        if query.is_empty() || text.contains(&query_lower) {
762                            let mut metadata = HashMap::new();
763                            metadata.insert("version".to_string(), version.to_string());
764                            if !category.is_empty() {
765                                metadata.insert("category".to_string(), category.to_string());
766                            }
767
768                            results.push(ProfileRef {
769                                id: format!("marketplace:{}@{}", name, channel.name),
770                                name: name.to_string(),
771                                owner: repo.split('/').next().unwrap_or("unknown").to_string(),
772                                description: description.to_string(),
773                                url: format!("https://github.com/{}", repo),
774                                stars: None,
775                                channel: channel.name.clone(),
776                                metadata,
777                            });
778                        }
779                    }
780                }
781            }
782        }
783
784        // Apply limit
785        if options.limit > 0 {
786            results.truncate(options.limit);
787        }
788
789        Ok(results)
790    }
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796
797    #[test]
798    fn parse_awesome_line_basic() {
799        let line =
800            "- [dotbot](https://github.com/anishathalye/dotbot) - A tool for managing dotfiles";
801        let result = ChannelManager::parse_awesome_line(line, "test");
802
803        assert!(result.is_some());
804        let profile = result.unwrap();
805        assert_eq!(profile.name, "dotbot");
806        assert_eq!(profile.owner, "anishathalye");
807        assert!(profile.description.contains("managing dotfiles"));
808    }
809
810    #[test]
811    fn parse_awesome_line_star() {
812        let line = "* [stow](https://github.com/aspiers/stow) - Symlink farm manager";
813        let result = ChannelManager::parse_awesome_line(line, "test");
814
815        assert!(result.is_some());
816        let profile = result.unwrap();
817        assert_eq!(profile.name, "stow");
818    }
819
820    #[test]
821    fn parse_awesome_line_no_description() {
822        let line = "- [tool](https://github.com/user/tool)";
823        let result = ChannelManager::parse_awesome_line(line, "test");
824
825        assert!(result.is_some());
826        let profile = result.unwrap();
827        assert_eq!(profile.name, "tool");
828        assert!(profile.description.is_empty());
829    }
830
831    #[test]
832    fn parse_awesome_line_skip_non_http() {
833        let line = "- [Section](#section)";
834        let result = ChannelManager::parse_awesome_line(line, "test");
835        assert!(result.is_none());
836    }
837
838    #[test]
839    fn to_raw_url_github() {
840        let url = "https://github.com/webpro/awesome-dotfiles";
841        let raw = ChannelManager::to_raw_url(url, "main");
842        assert!(raw.contains("raw.githubusercontent.com"));
843        assert!(raw.contains("README.md"));
844        assert!(raw.contains("/main/"));
845
846        let raw_master = ChannelManager::to_raw_url(url, "master");
847        assert!(raw_master.contains("/master/"));
848    }
849}