1use 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
14pub struct ChannelManager {
16 base_dir: PathBuf,
17 registry: ChannelRegistry,
18}
19
20impl ChannelManager {
21 pub fn new(base_dir: PathBuf) -> Result<Self> {
23 let registry = ChannelRegistry::load(&base_dir)?;
24 Ok(Self { base_dir, registry })
25 }
26
27 pub fn with_registry(base_dir: PathBuf, registry: ChannelRegistry) -> Self {
29 Self { base_dir, registry }
30 }
31
32 pub fn registry(&self) -> &ChannelRegistry {
34 &self.registry
35 }
36
37 pub fn registry_mut(&mut self) -> &mut ChannelRegistry {
39 &mut self.registry
40 }
41
42 pub fn save(&self) -> Result<()> {
44 self.registry.save(&self.base_dir)
45 }
46
47 pub fn search(&self, query: &str, options: &SearchOptions) -> Result<Vec<ProfileRef>> {
49 let mut results = Vec::new();
50
51 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 results.sort_by(|a, b| b.stars.cmp(&a.stars));
73
74 if options.limit > 0 {
76 results.truncate(options.limit);
77 }
78
79 Ok(results)
80 }
81
82 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 Ok(Vec::new())
95 }
96 }
97 }
98
99 fn search_github(
101 &self,
102 channel: &Channel,
103 query: &str,
104 options: &SearchOptions,
105 ) -> Result<Vec<ProfileRef>> {
106 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 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 if let Some(topic) = &options.topic {
126 args.push("--topic".to_string());
127 args.push(topic.clone());
128 }
129
130 args.push("--sort".to_string());
132 args.push(options.sort.clone().unwrap_or_else(|| "stars".to_string()));
133
134 let limit = if options.limit > 0 { options.limit } else { 10 };
136 args.push("--limit".to_string());
137 args.push(limit.to_string());
138
139 if let Some(min) = options.min_stars {
141 args.push("--stars".to_string());
142 args.push(format!(">={}", min));
143 }
144
145 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 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 let content = self.fetch_awesome_list(url, &channel.name)?;
216
217 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 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 if options.limit > 0 {
244 results.truncate(options.limit);
245 }
246
247 Ok(results)
248 }
249
250 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 if cache_file.exists() {
257 if let Ok(content) = std::fs::read_to_string(&cache_file) {
258 return Ok(content);
259 }
260 }
261
262 let raw_url = Self::to_raw_url(url);
264 let content = Self::fetch_url(&raw_url)?;
265
266 std::fs::create_dir_all(&cache_dir)?;
268 std::fs::write(&cache_file, &content)?;
269
270 Ok(content)
271 }
272
273 fn parse_awesome_line(line: &str, channel_name: &str) -> Option<ProfileRef> {
275 let line = line.trim();
278
279 if !line.starts_with('-') && !line.starts_with('*') {
280 return None;
281 }
282
283 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 if !url.starts_with("http://") && !url.starts_with("https://") {
298 return None;
299 }
300
301 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 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 fn extract_owner_from_url(url: &str) -> String {
330 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 fn to_raw_url(url: &str) -> String {
342 if url.contains("github.com") && !url.contains("raw.githubusercontent.com") {
343 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 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 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 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}