doiget_cli/commands/
source.rs1use std::io::Write;
24
25use anyhow::{Context, Result};
26use camino::{Utf8Path, Utf8PathBuf};
27
28use doiget_core::paper_tex_source::{
29 paper_source_bundle, resolve_arxiv_src_base, BundleFilter, SourceFile,
30};
31use doiget_core::{ArxivId, ErrorCode, Ref};
32
33use super::fetch::{build_resolve_context, cli_exit_code, CliExit};
34use super::output::print_err;
35use super::output::OutputMode;
36
37pub async fn run(
45 ref_: String,
46 out_dir: Utf8PathBuf,
47 figures_only: bool,
48 mode: OutputMode,
49 quiet_was_explicit: bool,
50) -> Result<()> {
51 let parsed = super::parse_ref_or_exit(&ref_)?;
52 let id: ArxivId = match parsed {
53 Ref::Arxiv(a) => a,
54 Ref::Doi(_) => {
55 let code = ErrorCode::NoOaAvailable;
56 print_err(format_args!(
57 "error[{}]: no source bundle for a bare DOI — if an arXiv preprint exists, \
58 pass its id (e.g. `doiget source arxiv:2401.12345 --out ./src`)",
59 code.as_wire()
60 ));
61 return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
62 }
63 };
64
65 let base = resolve_arxiv_src_base().map_err(|e| anyhow::anyhow!("{e}"))?;
66 let ctx = build_resolve_context().context("building fetch context")?;
67 let filter = if figures_only {
68 BundleFilter::FiguresOnly
69 } else {
70 BundleFilter::All
71 };
72
73 let files = match paper_source_bundle(&base, &id, filter, &ctx).await {
74 Ok(f) => f,
75 Err(e) => {
76 let code = ErrorCode::from(&e);
77 print_err(format_args!("error[{}]: {e}", code.as_wire()));
78 if code == ErrorCode::TextUnavailable {
79 print_err(format_args!(
80 " = note: no {} found (no matching files, PDF-only, or single-file \
81 submission). Fetch the PDF instead: `doiget fetch arxiv:{}`",
82 if figures_only {
83 "figures"
84 } else {
85 "source bundle"
86 },
87 id.as_str()
88 ));
89 }
90 return Err(anyhow::Error::new(CliExit(cli_exit_code(code))));
91 }
92 };
93
94 let written = write_files(&out_dir, &files)?;
95
96 if mode == OutputMode::Quiet && quiet_was_explicit {
100 return Ok(());
101 }
102
103 let stdout = std::io::stdout();
104 let mut out = stdout.lock();
105 if mode == OutputMode::Json {
106 let payload = serde_json::json!({
107 "ok": true,
108 "arxiv_id": id.as_str(),
109 "out_dir": out_dir.as_str(),
110 "figures_only": figures_only,
111 "count": written.len(),
112 "files": written.iter().map(|p| p.as_str()).collect::<Vec<_>>(),
113 });
114 let s = serde_json::to_string_pretty(&payload).context("serializing source JSON")?;
115 writeln!(out, "{s}").context("writing source JSON to stdout")?;
116 return Ok(());
117 }
118
119 writeln!(out, "wrote {} file(s) to {out_dir}", written.len())
120 .context("writing source summary")?;
121 for rel in &written {
122 writeln!(out, " {rel}").context("writing source file line")?;
123 }
124 Ok(())
125}
126
127fn write_files(out_dir: &Utf8Path, files: &[SourceFile]) -> Result<Vec<Utf8PathBuf>> {
135 std::fs::create_dir_all(out_dir.as_std_path())
136 .with_context(|| format!("creating output dir {out_dir}"))?;
137
138 let mut written: Vec<Utf8PathBuf> = Vec::with_capacity(files.len());
139 for f in files {
140 let rel = f.path();
141 let dest = out_dir.join(rel);
142 if !dest.starts_with(out_dir) {
148 anyhow::bail!("refusing to write outside the output dir (zip-slip guard): {rel}");
149 }
150 if let Some(parent) = dest.parent() {
153 if parent != out_dir {
154 std::fs::create_dir_all(parent.as_std_path())
155 .with_context(|| format!("creating {parent}"))?;
156 }
157 }
158 std::fs::write(dest.as_std_path(), &f.bytes).with_context(|| format!("writing {dest}"))?;
159 written.push(rel.to_owned());
160 }
161 written.sort();
162 Ok(written)
163}