1use crate::error::{GitHubDocsError, Result};
6use crate::types::{RepoName, RepoOwner, RepoSpec};
7use clap::Parser;
8use url::Url;
9
10#[derive(Parser, Debug)]
12#[command(author, version, about, long_about = None)]
13pub struct Args {
14 #[arg(short = 'r', long)]
16 pub repo: String,
17
18 #[arg(short = 'o', long, default_value = "downloads")]
20 pub output: String,
21
22 #[arg(long)]
24 pub list_only: bool,
25
26 #[arg(long, default_value = "true")]
28 pub recursive: bool,
29}
30
31impl Args {
32 pub fn parse_repo_spec(&self) -> Result<(RepoSpec, String)> {
44 let url = Url::parse(&self.repo)?;
45
46 if url.host_str() != Some("github.com") {
48 return Err(GitHubDocsError::InvalidRepoFormat {
49 input: self.repo.clone(),
50 });
51 }
52
53 let path_segments: Vec<&str> = url
54 .path_segments()
55 .ok_or_else(|| GitHubDocsError::InvalidRepoFormat {
56 input: self.repo.clone(),
57 })?
58 .collect();
59
60 if path_segments.len() < 5 || path_segments[2] != "tree" {
62 return Err(GitHubDocsError::InvalidRepoFormat {
63 input: format!("Expected GitHub tree URL format: https://github.com/owner/repo/tree/branch/path, got: {}", self.repo),
64 });
65 }
66
67 let owner = RepoOwner::new(path_segments[0])?;
68 let repo_name = RepoName::new(path_segments[1])?;
69 let repo_spec = RepoSpec::new(owner, repo_name);
70
71 let doc_path = path_segments[4..].join("/");
73
74 Ok((repo_spec, doc_path))
75 }
76
77 pub fn validate(&self) -> Result<()> {
83 let _ = self.parse_repo_spec()?;
85
86 if self.output.is_empty() {
88 return Err(GitHubDocsError::InvalidRepoFormat {
89 input: "Output directory cannot be empty".to_string(),
90 });
91 }
92
93 Ok(())
94 }
95}
96
97pub struct CliApp {
99 args: Args,
100}
101
102impl CliApp {
103 #[must_use]
105 pub fn new(args: Args) -> Self {
106 Self { args }
107 }
108
109 pub fn run(&self) -> Result<()> {
122 self.args.validate()?;
124
125 let (repo_spec, doc_path) = self.args.parse_repo_spec()?;
127
128 let config = crate::downloader::DownloadConfig {
130 output_dir: self.args.output.clone(),
131 list_only: self.args.list_only,
132 recursive: self.args.recursive,
133 target_path: doc_path,
134 };
135
136 let downloader = crate::downloader::GitHubDocsDownloader::new(repo_spec.clone(), config);
138
139 println!(
140 "Searching for documentation directories in {}...",
141 repo_spec.full_name()
142 );
143
144 let docs_dirs = downloader.find_docs_directories()?;
146
147 if docs_dirs.is_empty() {
148 return Err(GitHubDocsError::no_documentation_found(
149 repo_spec.owner.as_str(),
150 repo_spec.name.as_str(),
151 ));
152 }
153
154 println!("Found {} documentation directories:", docs_dirs.len());
155 for dir in &docs_dirs {
156 println!(" - {dir}");
157 }
158
159 let all_doc_files = downloader.get_all_documentation_files(&docs_dirs)?;
161
162 if all_doc_files.is_empty() {
163 println!("No documentation files found in the discovered directories.");
164 return Ok(());
165 }
166
167 downloader.download_files(&all_doc_files)?;
169
170 Ok(())
171 }
172
173 #[must_use]
175 pub fn args(&self) -> &Args {
176 &self.args
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 #[test]
185 fn test_parse_repo_spec_tree_url() {
186 let args = Args {
187 repo: "https://github.com/rust-lang/rust/tree/main/docs".to_string(),
188 output: "test".to_string(),
189 list_only: false,
190 recursive: true,
191 };
192
193 let (repo_spec, path) = args.parse_repo_spec().unwrap();
194 assert_eq!(repo_spec.owner.as_str(), "rust-lang");
195 assert_eq!(repo_spec.name.as_str(), "rust");
196 assert_eq!(path, "docs");
197 }
198
199 #[test]
200 fn test_parse_repo_spec_tree_url_nested_path() {
201 let args = Args {
202 repo: "https://github.com/TanStack/router/tree/main/docs/router/eslint".to_string(),
203 output: "test".to_string(),
204 list_only: false,
205 recursive: true,
206 };
207
208 let (repo_spec, path) = args.parse_repo_spec().unwrap();
209 assert_eq!(repo_spec.owner.as_str(), "TanStack");
210 assert_eq!(repo_spec.name.as_str(), "router");
211 assert_eq!(path, "docs/router/eslint");
212 }
213
214 #[test]
215 fn test_parse_repo_spec_invalid_url() {
216 let args = Args {
217 repo: "https://notgithub.com/owner/repo/tree/main/docs".to_string(),
218 output: "test".to_string(),
219 list_only: false,
220 recursive: true,
221 };
222
223 assert!(args.parse_repo_spec().is_err());
224 }
225
226 #[test]
227 fn test_parse_repo_spec_missing_tree_structure() {
228 let args = Args {
229 repo: "https://github.com/owner/repo".to_string(),
230 output: "test".to_string(),
231 list_only: false,
232 recursive: true,
233 };
234
235 assert!(args.parse_repo_spec().is_err());
236 }
237
238 #[test]
239 fn test_parse_repo_spec_invalid_format() {
240 let args = Args {
241 repo: "invalid-repo-format".to_string(),
242 output: "test".to_string(),
243 list_only: false,
244 recursive: true,
245 };
246
247 assert!(args.parse_repo_spec().is_err());
248 }
249}