1use std::fs;
2use std::path::Path;
3
4use chub_core::annotations::read_annotation;
5use chub_core::error::Result;
6use chub_core::fetch::{fetch_doc, fetch_doc_full};
7use chub_core::registry::{
8 get_entry, resolve_doc_path, resolve_entry_file, MergedRegistry, ResolvedPath,
9};
10use chub_core::team::analytics;
11use clap::Args;
12
13use crate::output;
14
15#[derive(Args)]
16pub struct GetArgs {
17 ids: Vec<String>,
19
20 #[arg(long)]
22 lang: Option<String>,
23
24 #[arg(long)]
26 version: Option<String>,
27
28 #[arg(short, long)]
30 output: Option<String>,
31
32 #[arg(long)]
34 full: bool,
35
36 #[arg(long)]
38 file: Option<String>,
39
40 #[arg(long)]
42 pinned: bool,
43
44 #[arg(long)]
46 match_env: bool,
47}
48
49struct FetchedEntry {
50 id: String,
51 entry_type: String,
52 content: Option<String>,
53 files: Option<Vec<(String, String)>>,
54 path: String,
55 additional_files: Vec<String>,
56}
57
58pub async fn run(args: GetArgs, json: bool, merged: &MergedRegistry) -> Result<()> {
59 if args.pinned {
61 let pins = chub_core::team::pins::list_pins();
62 if pins.is_empty() {
63 output::error("No pinned docs. Use `chub pin add <id>` to pin docs.", json);
64 std::process::exit(1);
65 }
66 let pin_ids: Vec<String> = pins.iter().map(|p| p.id.clone()).collect();
67 let pinned_args = GetArgs {
68 ids: pin_ids,
69 lang: args.lang.clone(),
70 version: args.version.clone(),
71 output: args.output.clone(),
72 full: args.full,
73 file: args.file.clone(),
74 pinned: false,
75 match_env: args.match_env,
76 };
77 return Box::pin(run(pinned_args, json, merged)).await;
78 }
79
80 if args.ids.is_empty() {
81 output::error("Missing required argument: <ids>", json);
82 std::process::exit(1);
83 }
84
85 let mut results: Vec<FetchedEntry> = Vec::new();
86
87 for id in &args.ids {
88 if let Some(ctx_name) = id.strip_prefix("project/") {
90 if let Some((_doc, content)) = chub_core::team::context::get_context_doc(ctx_name) {
91 results.push(FetchedEntry {
92 id: id.clone(),
93 entry_type: "context".to_string(),
94 content: Some(content),
95 files: None,
96 path: format!(".chub/context/{}.md", ctx_name),
97 additional_files: vec![],
98 });
99 continue;
100 } else {
101 output::error(
102 &format!(
103 "Project context doc \"{}\" not found in .chub/context/",
104 ctx_name
105 ),
106 json,
107 );
108 std::process::exit(1);
109 }
110 }
111 let result = get_entry(id, merged);
112
113 if result.ambiguous {
114 let alts = result.alternatives.join(", ");
115 output::error(
116 &format!(
117 "Multiple entries match \"{}\". Use a source prefix: {}",
118 id, alts
119 ),
120 json,
121 );
122 std::process::exit(1);
123 }
124
125 let entry = match result.entry {
126 Some(e) => e,
127 None => {
128 output::error(&format!("No doc or skill found with id \"{}\".", id), json);
129 std::process::exit(1);
130 }
131 };
132
133 let entry_type = entry.entry_type.to_string();
134
135 let mut effective_lang = args.lang.clone();
137 let mut effective_version = args.version.clone();
138 if let Some(pin) = chub_core::team::pins::get_pin(entry.id()) {
139 if effective_lang.is_none() {
140 effective_lang = pin.lang.clone();
141 }
142 if effective_version.is_none() {
143 effective_version = pin.version.clone();
144 }
145 }
146
147 if args.match_env && effective_version.is_none() {
149 let cwd = std::env::current_dir().unwrap_or_default();
150 let deps = chub_core::team::detect::detect_dependencies(&cwd);
151 if let Some(dep) = find_matching_dep_for_entry(entry.id(), &deps) {
152 if let Some(ref ver) = dep.version {
153 let clean = ver.trim_start_matches(|c: char| {
154 c == '^' || c == '~' || c == '=' || c == '>' || c == '<' || c == 'v'
155 });
156 if !clean.is_empty() {
157 effective_version = Some(clean.to_string());
158 }
159 }
160 }
161 }
162
163 let mut resolved = resolve_doc_path(
164 &entry,
165 effective_lang.as_deref(),
166 effective_version.as_deref(),
167 );
168
169 if resolved.is_none() && effective_lang.is_some() {
172 resolved = resolve_doc_path(&entry, None, effective_version.as_deref());
173 }
174
175 let resolved = match resolved {
176 Some(r) => r,
177 None => {
178 output::error(&format!("No content found for \"{}\".", id), json);
179 std::process::exit(1);
180 }
181 };
182
183 match &resolved {
184 ResolvedPath::VersionNotFound {
185 requested,
186 available,
187 } => {
188 output::error(
189 &format!(
190 "Version \"{}\" not found for \"{}\". Available: {}",
191 requested,
192 id,
193 available.join(", ")
194 ),
195 json,
196 );
197 std::process::exit(1);
198 }
199 ResolvedPath::NeedsLanguage { available } => {
200 output::error(
201 &format!(
202 "Multiple languages available for \"{}\": {}. Specify --lang.",
203 id,
204 available.join(", ")
205 ),
206 json,
207 );
208 std::process::exit(1);
209 }
210 ResolvedPath::Ok { .. } => {}
211 }
212
213 let (file_path, base_path, files) = match resolve_entry_file(&resolved, &entry_type) {
214 Some(r) => r,
215 None => {
216 output::error(
217 &format!("No content available for \"{}\". Run `chub update`.", id),
218 json,
219 );
220 std::process::exit(1);
221 }
222 };
223
224 let entry_file_name = if entry_type == "skill" {
225 "SKILL.md"
226 } else {
227 "DOC.md"
228 };
229 let ref_files: Vec<String> = files
230 .iter()
231 .filter(|f| f.as_str() != entry_file_name)
232 .cloned()
233 .collect();
234
235 let source = match &resolved {
236 ResolvedPath::Ok { source, .. } => source.clone(),
237 _ => unreachable!(),
238 };
239
240 if let Some(ref file_arg) = args.file {
241 let requested: Vec<&str> = file_arg.split(',').map(|f| f.trim()).collect();
242 let invalid: Vec<&&str> = requested
243 .iter()
244 .filter(|f| !files.contains(&f.to_string()))
245 .collect();
246 if !invalid.is_empty() {
247 let available = if ref_files.is_empty() {
248 "(none)".to_string()
249 } else {
250 ref_files.join(", ")
251 };
252 output::error(
253 &format!(
254 "File \"{}\" not found in {}. Available: {}",
255 invalid[0], id, available
256 ),
257 json,
258 );
259 std::process::exit(1);
260 }
261 if requested.len() == 1 {
262 let path = format!("{}/{}", base_path, requested[0]);
263 let content = fetch_doc(&source, &path).await?;
264 results.push(FetchedEntry {
265 id: entry.id().to_string(),
266 entry_type: entry_type.clone(),
267 content: Some(content),
268 files: None,
269 path,
270 additional_files: vec![],
271 });
272 } else {
273 let req_strings: Vec<String> = requested.iter().map(|s| s.to_string()).collect();
274 let all_files = fetch_doc_full(&source, &base_path, &req_strings).await?;
275 results.push(FetchedEntry {
276 id: entry.id().to_string(),
277 entry_type: entry_type.clone(),
278 content: None,
279 files: Some(all_files),
280 path: base_path,
281 additional_files: vec![],
282 });
283 }
284 } else if args.full && !files.is_empty() {
285 let all_files = fetch_doc_full(&source, &base_path, &files).await?;
286 results.push(FetchedEntry {
287 id: entry.id().to_string(),
288 entry_type: entry_type.clone(),
289 content: None,
290 files: Some(all_files),
291 path: base_path,
292 additional_files: vec![],
293 });
294 } else {
295 let content = fetch_doc(&source, &file_path).await?;
296 results.push(FetchedEntry {
297 id: entry.id().to_string(),
298 entry_type: entry_type.clone(),
299 content: Some(content),
300 files: None,
301 path: file_path,
302 additional_files: ref_files,
303 });
304 }
305 }
306
307 for r in &results {
309 analytics::record_fetch(&r.id, None);
310 }
311
312 if let Some(ref output_path) = args.output {
314 write_output(&results, output_path, &args, json)?;
315 } else {
316 print_output(&results, &args, json);
317 }
318
319 Ok(())
320}
321
322fn write_output(
323 results: &[FetchedEntry],
324 output_path: &str,
325 args: &GetArgs,
326 json: bool,
327) -> Result<()> {
328 if args.full {
329 for r in results {
330 if let Some(ref files) = r.files {
331 let base_dir = if results.len() > 1 {
332 Path::new(output_path).join(&r.id)
333 } else {
334 Path::new(output_path).to_path_buf()
335 };
336 fs::create_dir_all(&base_dir)?;
337 for (name, content) in files {
338 let out_path = base_dir.join(name);
339 if let Some(parent) = out_path.parent() {
340 fs::create_dir_all(parent)?;
341 }
342 fs::write(&out_path, content)?;
343 }
344 output::info(&format!(
345 "Written {} files to {}",
346 files.len(),
347 base_dir.display()
348 ));
349 } else if let Some(ref content) = r.content {
350 let out_path = Path::new(output_path).join(format!("{}.md", r.id));
351 if let Some(parent) = out_path.parent() {
352 fs::create_dir_all(parent)?;
353 }
354 fs::write(&out_path, content)?;
355 output::info(&format!("Written to {}", out_path.display()));
356 }
357 }
358 } else {
359 let is_dir = output_path.ends_with('/') || output_path.ends_with('\\');
360 if is_dir && results.len() > 1 {
361 fs::create_dir_all(output_path)?;
362 for r in results {
363 if let Some(ref content) = r.content {
364 let out_path = Path::new(output_path).join(format!("{}.md", r.id));
365 if let Some(parent) = out_path.parent() {
366 fs::create_dir_all(parent)?;
367 }
368 fs::write(&out_path, content)?;
369 output::info(&format!("Written to {}", out_path.display()));
370 }
371 }
372 } else {
373 let out_path = if is_dir {
374 Path::new(output_path).join(format!("{}.md", results[0].id))
375 } else {
376 Path::new(output_path).to_path_buf()
377 };
378 if let Some(parent) = out_path.parent() {
379 fs::create_dir_all(parent)?;
380 }
381 let combined: String = results
382 .iter()
383 .filter_map(|r| r.content.as_deref())
384 .collect::<Vec<_>>()
385 .join("\n\n---\n\n");
386 fs::write(&out_path, &combined)?;
387 output::info(&format!("Written to {}", out_path.display()));
388 }
389 }
390
391 if json {
392 let json_out: Vec<serde_json::Value> = results
393 .iter()
394 .map(|r| {
395 serde_json::json!({
396 "id": r.id,
397 "type": r.entry_type,
398 "path": output_path,
399 })
400 })
401 .collect();
402 println!("{}", serde_json::to_string(&json_out).unwrap_or_default());
403 }
404
405 Ok(())
406}
407
408fn print_output(results: &[FetchedEntry], args: &GetArgs, json: bool) {
409 if results.len() == 1 && results[0].files.is_none() {
410 let r = &results[0];
411 let content = r.content.as_deref().unwrap_or("");
412 let annotation = read_annotation(&r.id);
413
414 if json {
415 let mut data = serde_json::json!({
416 "id": r.id,
417 "type": r.entry_type,
418 "content": content,
419 "path": r.path,
420 });
421 if !r.additional_files.is_empty() {
422 data["additionalFiles"] = serde_json::json!(r.additional_files);
423 }
424 if let Some(ref ann) = annotation {
425 data["annotation"] = serde_json::json!(ann);
426 }
427 println!(
428 "{}",
429 serde_json::to_string_pretty(&data).unwrap_or_default()
430 );
431 } else {
432 print!("{}", content);
433 if let Some(ref ann) = annotation {
434 print!(
435 "\n\n---\n⚠ USER-CONTRIBUTED ANNOTATION (not part of official documentation):\n[Agent note — {}]\n{}\n",
436 ann.updated_at, ann.note
437 );
438 }
439 let lang_flag = args
440 .lang
441 .as_ref()
442 .map(|l| format!(" --lang {}", l))
443 .unwrap_or_default();
444 println!("\n\n---\nAfter using this doc, share your experience:");
445 println!(" chub feedback {} up{}", r.id, lang_flag);
446 println!(
447 " chub feedback {} down{} --label outdated",
448 r.id, lang_flag
449 );
450 println!("Available labels: accurate, well-structured, helpful, good-examples, outdated, inaccurate, incomplete, wrong-examples, wrong-version, poorly-structured");
451 println!("Do NOT include any code, architecture details, or project-specific information in your feedback.");
452 if !r.additional_files.is_empty() {
453 let file_list = r
454 .additional_files
455 .iter()
456 .map(|f| format!(" {}", f))
457 .collect::<Vec<_>>()
458 .join("\n");
459 let example = format!("chub get {} --file {}", r.id, r.additional_files[0]);
460 print!(
461 "\n\n---\nAdditional files available (use --file to fetch):\n{}\nExample: {}\n",
462 file_list, example
463 );
464 }
465 }
466 } else {
467 let parts: Vec<String> = results
468 .iter()
469 .flat_map(|r| {
470 if let Some(ref files) = r.files {
471 files
472 .iter()
473 .map(|(name, content)| format!("# FILE: {}\n\n{}", name, content))
474 .collect()
475 } else {
476 vec![r.content.clone().unwrap_or_default()]
477 }
478 })
479 .collect();
480 let combined = parts.join("\n\n---\n\n");
481
482 if json {
483 let json_out: Vec<serde_json::Value> = results
484 .iter()
485 .map(|r| {
486 let mut obj = serde_json::json!({
487 "id": r.id,
488 "type": r.entry_type,
489 "path": r.path,
490 });
491 if let Some(ref content) = r.content {
492 obj["content"] = serde_json::json!(content);
493 }
494 if let Some(ref files) = r.files {
495 obj["files"] = serde_json::json!(files);
496 }
497 obj
498 })
499 .collect();
500 println!(
501 "{}",
502 serde_json::to_string_pretty(&json_out).unwrap_or_default()
503 );
504 } else {
505 print!("{}", combined);
506 }
507 }
508}
509
510fn find_matching_dep_for_entry<'a>(
514 entry_id: &str,
515 deps: &'a [chub_core::team::detect::DetectedDep],
516) -> Option<&'a chub_core::team::detect::DetectedDep> {
517 let id_parts: Vec<&str> = entry_id.split('/').collect();
518 let search_name = if !id_parts.is_empty() {
519 id_parts[0].to_lowercase()
520 } else {
521 entry_id.to_lowercase()
522 };
523 deps.iter().find(|d| d.name.to_lowercase() == search_name)
524}