Skip to main content

apr_cli/commands/
pull.rs

1//! Pull command: download and cache models from HuggingFace (`~/.cache/pacha/models/`).
2
3use crate::error::{CliError, Result};
4use colored::Colorize;
5use pacha::fetcher::{FetchConfig, ModelFetcher};
6use pacha::format::ModelFormat;
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet};
9use std::io::{self, Read, Write};
10use std::path::Path;
11
12/// Result of resolving a HuggingFace model reference.
13///
14/// Single-file models (small SafeTensors, GGUF) use the pacha fetcher.
15/// Sharded models (3B+ SafeTensors) are downloaded directly to `~/.apr/cache/hf/`.
16#[derive(Debug)]
17pub(crate) enum ResolvedModel {
18    /// Single file downloadable via pacha (existing behavior)
19    SingleFile(String),
20    /// Sharded SafeTensors model (multiple .safetensors files + index.json)
21    Sharded {
22        org: String,
23        repo: String,
24        shard_files: Vec<String>,
25    },
26}
27
28/// GH-213: Manifest recording checksums for each file in a sharded download.
29///
30/// Written to `.apr-manifest.json` in the cache directory after a successful download.
31/// Used by the pre-inference contract gate to verify shard integrity without re-hashing.
32#[derive(Debug, Serialize, Deserialize)]
33pub struct ShardManifest {
34    pub version: u32,
35    pub repo: String,
36    pub files: HashMap<String, FileChecksum>,
37}
38
39/// GH-213: Size and BLAKE3 hash of a downloaded file.
40#[derive(Debug, Serialize, Deserialize)]
41pub struct FileChecksum {
42    pub size: u64,
43    pub blake3: String,
44}
45
46/// Run the pull command
47#[provable_contracts_macros::contract(
48    "apr-cli-operations-v1",
49    equation = "mutating_output_contract"
50)]
51pub fn run(
52    model_ref: &str,
53    force: bool,
54    dry_run: bool,
55    revision: Option<&str>,
56    offline: bool,
57    json: bool,
58) -> Result<()> {
59    contract_pre_pull_cache_integrity!();
60
61    // CRUX-A-01 FALSIFY-CRUX-A-01-001: --dry-run resolves short name to
62    // canonical URL and exits with zero network I/O.
63    // CRUX-A-03 ALGO-001..003: --dry-run echoes the revision spec the user
64    // supplied (or the default "main") and validates its local form.
65    // CRUX-A-20 ALGO-001..005: --dry-run also echoes the resolved offline
66    // mode so callers can assert CLI-flag / env-var equivalence offline.
67    //
68    // The banner lives inside the dry-run renderer rather than out here so
69    // that `--json` suppresses it only on the path that actually replaces it
70    // with a document. The download path below still prints progress and
71    // usage hints and is knowingly not JSON-clean yet; half-suppressing its
72    // banner would only make unparseable output look intentional.
73    if dry_run {
74        return run_dry_run(model_ref, revision, offline, json);
75    }
76
77    println!("{}", "=== APR Pull ===".cyan().bold());
78    println!();
79
80    // CRUX-A-20: the `offline` parameter used to be read ONLY inside the
81    // `dry_run` branch above, so `apr pull --offline hf://org/repo` performed
82    // a full download and exited 0 — a compliance control that controlled
83    // nothing. Feed it into the same enforcement point every download helper
84    // consults; `hf_get` then refuses every outbound request.
85    //
86    // A cache hit still succeeds offline when the URI names the file
87    // (`hf://org/repo/model.safetensors` → `run_single_file_streaming` returns
88    // from the cache before any request). A bare `hf://org/repo` cannot: the
89    // filename is only knowable from the Hub API, so offline it is refused
90    // rather than guessed.
91    //
92    // The scope is established before `resolve_hf_model` below, which is the
93    // first thing on this path that can reach the network.
94    let _offline_scope = super::offline::scope(offline);
95
96    // GH-213: Resolve HuggingFace URI — detect single vs sharded models
97    let resolved = resolve_hf_model(model_ref)?;
98
99    let result = match resolved {
100        ResolvedModel::SingleFile(ref uri) => run_single_file(uri, force),
101        ResolvedModel::Sharded {
102            ref org,
103            ref repo,
104            ref shard_files,
105        } => run_sharded(org, repo, shard_files, force),
106    };
107    if let Ok(ref r) = result {
108        contract_post_pull_cache_integrity!(r);
109    }
110    result
111}
112
113/// Pull a single-file model.
114///
115/// GH-352: For HuggingFace URIs, streams directly to disk instead of buffering
116/// the entire file in memory through pacha's resolver. For non-HF URIs (pacha
117/// aliases), falls back to the pacha fetcher.
118fn run_single_file(model_ref: &str, force: bool) -> Result<()> {
119    println!("Model: {}", model_ref.cyan());
120
121    // GH-352: HuggingFace URIs bypass pacha to avoid O(model_size) memory buffering
122    if model_ref.starts_with("hf://") {
123        return run_single_file_streaming(model_ref, force);
124    }
125
126    let mut fetcher = ModelFetcher::with_config(FetchConfig::default()).map_err(|e| {
127        CliError::ValidationFailed(format!("Failed to initialize model fetcher: {e}"))
128    })?;
129
130    if !force && fetcher.is_cached(model_ref) {
131        return handle_cached_model(&mut fetcher, model_ref);
132    }
133
134    let result = download_single_model(&mut fetcher, model_ref)?;
135    ensure_safetensors_companions(&result)?;
136    print_pull_usage(&result.path, true);
137    Ok(())
138}
139
140/// GH-352: Stream a single HuggingFace file directly to disk.
141///
142/// Uses O(64KB) memory instead of O(model_size). The pacha fetcher's
143/// `resolver.resolve()` buffers the entire response via `response.bytes()`,
144/// which consumed ~4.5 GB for a 7B GGUF. This function streams with a 64KB
145/// chunked read, computes BLAKE3 incrementally, and saves to the pacha cache.
146fn run_single_file_streaming(model_ref: &str, force: bool) -> Result<()> {
147    let (org, repo, filename) = parse_hf_single_uri(model_ref)?;
148    let url = format!("https://huggingface.co/{org}/{repo}/resolve/main/{filename}");
149
150    let cache_dir = get_pacha_cache_dir()?;
151    std::fs::create_dir_all(&cache_dir)?;
152    let (extension, cache_path) = build_single_cache_path(&cache_dir, model_ref, &filename);
153
154    if !force && cache_path.exists() {
155        return report_cached_single(&cache_path);
156    }
157
158    stream_and_post_process(&url, &cache_path, model_ref, &extension)?;
159    print_pull_usage(&cache_path, true);
160    Ok(())
161}
162
163fn stream_and_post_process(
164    url: &str,
165    cache_path: &std::path::Path,
166    model_ref: &str,
167    extension: &str,
168) -> Result<()> {
169    println!();
170    println!("{}", "Downloading (streaming)...".yellow());
171    let checksum = download_file_with_progress(url, cache_path)?;
172    report_downloaded_single(cache_path, &checksum);
173
174    if extension == "safetensors" {
175        fetch_safetensors_companions(cache_path, model_ref)?;
176        convert_safetensors_formats(cache_path)?;
177    }
178    Ok(())
179}
180
181fn parse_hf_single_uri(model_ref: &str) -> Result<(String, String, String)> {
182    let path = model_ref.strip_prefix("hf://").unwrap_or(model_ref);
183    let parts: Vec<&str> = path.split('/').collect();
184    if parts.len() < 3 {
185        return Err(CliError::ValidationFailed(format!(
186            "HuggingFace URI must include a filename: {model_ref}"
187        )));
188    }
189    Ok((
190        parts[0].to_string(),
191        parts[1].to_string(),
192        parts[2..].join("/"),
193    ))
194}
195
196pub(crate) fn build_single_cache_path(
197    cache_dir: &std::path::Path,
198    model_ref: &str,
199    filename: &str,
200) -> (String, std::path::PathBuf) {
201    let uri_hash = blake3::hash(model_ref.as_bytes()).to_hex().to_string();
202    let extension = std::path::Path::new(filename)
203        .extension()
204        .and_then(|e| e.to_str())
205        .unwrap_or("bin")
206        .to_string();
207    let cache_filename = format!("{}.{extension}", &uri_hash[..16]);
208    let cache_path = cache_dir.join(&cache_filename);
209    (extension, cache_path)
210}
211
212fn report_cached_single(cache_path: &std::path::Path) -> Result<()> {
213    let metadata = std::fs::metadata(cache_path)?;
214    println!("{} Model already cached", "✓".green());
215    println!("  Path: {}", cache_path.display());
216    println!("  Size: {}", format_bytes(metadata.len()));
217    print_pull_usage(cache_path, true);
218    Ok(())
219}
220
221fn report_downloaded_single(cache_path: &std::path::Path, checksum: &FileChecksum) {
222    println!();
223    println!("{} Downloaded successfully", "✓".green());
224    println!("  Path: {}", cache_path.display().to_string().green());
225    println!("  Size: {}", format_bytes(checksum.size).yellow());
226    println!("  Hash: {}", &checksum.blake3[..16]);
227}
228
229/// Get the pacha model cache directory.
230pub(crate) fn get_pacha_cache_dir() -> Result<std::path::PathBuf> {
231    if let Ok(cache_home) = std::env::var("XDG_CACHE_HOME") {
232        return Ok(std::path::PathBuf::from(cache_home)
233            .join("pacha")
234            .join("models"));
235    }
236    Ok(dirs::home_dir()
237        .ok_or_else(|| CliError::ValidationFailed("Cannot find home directory".to_string()))?
238        .join(".cache")
239        .join("pacha")
240        .join("models"))
241}
242
243/// Handle a model that is already cached in pacha.
244fn handle_cached_model(fetcher: &mut ModelFetcher, model_ref: &str) -> Result<()> {
245    println!("{} Model already cached", "✓".green());
246    let result = fetcher
247        .pull_quiet(model_ref)
248        .map_err(|e| CliError::ValidationFailed(format!("Failed to get cached model: {e}")))?;
249
250    println!("  Path: {}", result.path.display());
251    println!("  Size: {}", result.size_human());
252    println!("  Format: {}", result.format.name());
253
254    ensure_safetensors_companions(&result)?;
255    print_pull_usage(&result.path, false);
256    Ok(())
257}
258
259/// Download a single model with progress bar.
260fn download_single_model(
261    fetcher: &mut ModelFetcher,
262    model_ref: &str,
263) -> Result<pacha::fetcher::FetchResult> {
264    // CRUX-A-20: the pacha fetcher opens its own sockets, so it does not pass
265    // through `hf_get`. The cache-hit path above already returned, so reaching
266    // here means a real download is about to start.
267    super::offline::guard(&format!("download {model_ref}"))?;
268    println!();
269    println!("{}", "Downloading...".yellow());
270
271    let result = fetcher
272        .pull(model_ref, |progress| {
273            let pct = progress.percent();
274            print!(
275                "\r  [{:50}] {:5.1}% ({}/{})",
276                "=".repeat((pct / 2.0) as usize),
277                pct,
278                format_bytes(progress.downloaded_bytes),
279                format_bytes(progress.total_bytes)
280            );
281            io::stdout().flush().ok();
282        })
283        .map_err(|e| CliError::NetworkError(format!("Download failed: {e}")))?;
284
285    println!();
286    println!();
287
288    if result.cache_hit {
289        println!("{} Model retrieved from cache", "✓".green());
290    } else {
291        println!("{} Downloaded successfully", "✓".green());
292    }
293
294    println!("  Path: {}", result.path.display().to_string().green());
295    println!("  Size: {}", result.size_human().yellow());
296    println!("  Format: {}", result.format.name());
297    println!("  Hash: {}", &result.hash[..16]);
298    Ok(result)
299}
300
301/// Ensure companion files exist for SafeTensors models (GH-198, GH-211).
302fn ensure_safetensors_companions(result: &pacha::fetcher::FetchResult) -> Result<()> {
303    if matches!(result.format, ModelFormat::SafeTensors(_)) {
304        fetch_safetensors_companions(&result.path, &result.resolved_uri)?;
305        convert_safetensors_formats(&result.path)?;
306    }
307    Ok(())
308}
309
310/// Print usage instructions after a successful pull.
311fn print_pull_usage(path: &Path, show_serve: bool) {
312    println!();
313    println!("{}", "Usage:".cyan().bold());
314    println!("  apr run {}", path.display());
315    if show_serve {
316        println!("  apr serve {}", path.display());
317    }
318}
319
320/// GH-213: Pull a sharded SafeTensors model (3B+ models with multiple shard files)
321fn run_sharded(org: &str, repo: &str, shard_files: &[String], force: bool) -> Result<()> {
322    println!(
323        "Model: {}/{} ({} shards)",
324        org.cyan(),
325        repo.cyan(),
326        shard_files.len().to_string().yellow()
327    );
328
329    let cache_dir = resolve_shard_cache_dir(org, repo)?;
330    std::fs::create_dir_all(&cache_dir)?;
331
332    let base_url = format!("https://huggingface.co/{org}/{repo}/resolve/main");
333
334    // #1893: sharded GGUF (`model-NNNNN-of-MMMMM.gguf`) has NO index.json and
335    // needs no SafeTensors conversion — download all parts and point usage at
336    // the first part (GGUF split loaders find the rest via split.* metadata).
337    if shard_files
338        .iter()
339        .all(|f| f.to_lowercase().ends_with(".gguf"))
340    {
341        return run_sharded_gguf(org, repo, &cache_dir, &base_url, shard_files, force);
342    }
343
344    let index_path = cache_dir.join("model.safetensors.index.json");
345
346    download_index_if_needed(&base_url, &index_path, force)?;
347
348    let manifest_path = cache_dir.join(".apr-manifest.json");
349    let existing_manifest = load_existing_manifest(&manifest_path, force);
350
351    let file_checksums = download_all_shards(
352        &cache_dir,
353        &base_url,
354        shard_files,
355        force,
356        existing_manifest.as_ref(),
357    )?;
358
359    download_companion_files(&cache_dir, &base_url, force)?;
360    write_shard_manifest(&manifest_path, org, repo, file_checksums)?;
361
362    println!();
363    println!("{} Downloaded successfully", "✓".green());
364    println!("  Path: {}", index_path.display().to_string().green());
365    println!("  Shards: {}", shard_files.len().to_string().yellow());
366
367    convert_safetensors_formats(&index_path)?;
368
369    println!();
370    println!("{}", "Usage:".cyan().bold());
371    println!("  apr run {}", index_path.display());
372    println!("  apr serve {}", index_path.display());
373    Ok(())
374}
375
376/// #1893: Download a sharded GGUF model. Unlike sharded SafeTensors there is no
377/// `index.json` and no format conversion — fetch all `-of-` parts and point
378/// usage at the first part (GGUF split loaders open part 1 and discover the
379/// siblings via the `split.count` / `split.no` metadata keys).
380fn run_sharded_gguf(
381    org: &str,
382    repo: &str,
383    cache_dir: &Path,
384    base_url: &str,
385    shard_files: &[String],
386    force: bool,
387) -> Result<()> {
388    let manifest_path = cache_dir.join(".apr-manifest.json");
389    let existing_manifest = load_existing_manifest(&manifest_path, force);
390
391    let file_checksums = download_all_shards(
392        cache_dir,
393        base_url,
394        shard_files,
395        force,
396        existing_manifest.as_ref(),
397    )?;
398
399    download_companion_files(cache_dir, base_url, force)?;
400    write_shard_manifest(&manifest_path, org, repo, file_checksums)?;
401
402    println!();
403    println!(
404        "{} Downloaded {} GGUF shards",
405        "✓".green(),
406        shard_files.len().to_string().yellow()
407    );
408
409    // #1893 criterion 2: merge the parts into one loadable GGUF so the existing
410    // single-file loader runs the model unchanged ("without manual
411    // pre-stitching").
412    let part_paths: Vec<std::path::PathBuf> =
413        shard_files.iter().map(|f| cache_dir.join(f)).collect();
414    let merged_path = cache_dir.join("model.gguf");
415    match aprender::format::gguf::merge_gguf_shards(&part_paths, &merged_path) {
416        Ok(()) => {
417            // The merged model supersedes the parts — delete them so the model
418            // doesn't occupy ~2× its size on disk indefinitely.
419            for part in &part_paths {
420                if let Err(e) = std::fs::remove_file(part) {
421                    eprintln!(
422                        "  {} could not remove shard {} ({e})",
423                        "!".yellow(),
424                        part.display()
425                    );
426                }
427            }
428            println!(
429                "  {} merged {} parts → model.gguf",
430                "✓".green(),
431                shard_files.len().to_string().yellow()
432            );
433            println!("  Path: {}", merged_path.display().to_string().green());
434            println!();
435            println!("{}", "Usage:".cyan().bold());
436            println!("  apr run {}", merged_path.display());
437            println!("  apr serve {}", merged_path.display());
438        }
439        Err(e) => {
440            // Honest failure: the individual parts are NOT independently
441            // runnable, so do not point `apr run` at one of them.
442            eprintln!("  {} could not assemble the sharded model: {e}", "✗".red());
443            eprintln!(
444                "  The {} parts were downloaded to {} but cannot be run \
445                 individually. Please file an issue (#1893) with the model name.",
446                shard_files.len(),
447                cache_dir.display()
448            );
449            return Err(CliError::ValidationFailed(format!(
450                "sharded GGUF merge failed: {e}"
451            )));
452        }
453    }
454    Ok(())
455}
456
457/// Resolve the on-disk cache directory for a model reference, without any
458/// network I/O.
459///
460/// Used by `apr pull --verify`, which inspects an already-downloaded model.
461/// Accepts `hf://org/repo`, `org/repo`, or a bare path to a cache directory.
462pub(crate) fn resolve_cache_dir_for_ref(model_ref: &str) -> Result<std::path::PathBuf> {
463    // An explicit directory wins - lets the operator verify any cache layout.
464    let as_path = std::path::Path::new(model_ref);
465    if as_path.is_dir() {
466        return Ok(as_path.to_path_buf());
467    }
468    let trimmed = model_ref
469        .trim_start_matches("hf://")
470        .trim_start_matches("https://huggingface.co/")
471        .trim_matches('/');
472    let mut parts = trimmed.splitn(2, '/');
473    match (parts.next(), parts.next()) {
474        (Some(org), Some(repo)) if !org.is_empty() && !repo.is_empty() => {
475            resolve_shard_cache_dir(org, repo)
476        }
477        _ => Err(CliError::ValidationFailed(format!(
478            "Cannot resolve a cache directory from '{model_ref}'. \
479             Expected `org/repo`, `hf://org/repo`, or an existing directory."
480        ))),
481    }
482}
483
484/// Resolve the cache directory for a sharded model.
485fn resolve_shard_cache_dir(org: &str, repo: &str) -> Result<std::path::PathBuf> {
486    Ok(dirs::home_dir()
487        .ok_or_else(|| CliError::ValidationFailed("Cannot find home directory".to_string()))?
488        .join(".apr")
489        .join("cache")
490        .join("hf")
491        .join(org)
492        .join(repo))
493}
494
495/// Download the SafeTensors index.json if not already cached.
496fn download_index_if_needed(base_url: &str, index_path: &Path, force: bool) -> Result<()> {
497    if force || !index_path.exists() {
498        println!();
499        println!("  {} model.safetensors.index.json", "Downloading".yellow());
500        download_file(
501            &format!("{base_url}/model.safetensors.index.json"),
502            index_path,
503        )?;
504    } else {
505        println!("  {} model.safetensors.index.json (cached)", "✓".green());
506    }
507    Ok(())
508}
509
510/// Load existing shard manifest for cache-hit verification (GH-213).
511fn load_existing_manifest(manifest_path: &Path, force: bool) -> Option<ShardManifest> {
512    if force || !manifest_path.exists() {
513        return None;
514    }
515    std::fs::read_to_string(manifest_path)
516        .ok()
517        .and_then(|s| serde_json::from_str(&s).ok())
518}
519
520/// Download all shards, collecting checksums for the manifest.
521fn download_all_shards(
522    cache_dir: &Path,
523    base_url: &str,
524    shard_files: &[String],
525    force: bool,
526    existing_manifest: Option<&ShardManifest>,
527) -> Result<HashMap<String, FileChecksum>> {
528    let mut file_checksums: HashMap<String, FileChecksum> = HashMap::new();
529    let total = shard_files.len();
530    for (i, shard_file) in shard_files.iter().enumerate() {
531        download_or_verify_shard(
532            cache_dir,
533            base_url,
534            shard_file,
535            i,
536            total,
537            force,
538            existing_manifest,
539            &mut file_checksums,
540        )?;
541    }
542    Ok(file_checksums)
543}
544
545/// Download or verify a single shard file, updating the checksum map.
546fn download_or_verify_shard(
547    cache_dir: &Path,
548    base_url: &str,
549    shard_file: &str,
550    index: usize,
551    total: usize,
552    force: bool,
553    existing_manifest: Option<&ShardManifest>,
554    checksums: &mut HashMap<String, FileChecksum>,
555) -> Result<()> {
556    let shard_path = cache_dir.join(shard_file);
557
558    if !force && shard_path.exists() {
559        if let Some(manifest) = existing_manifest {
560            if let Some(expected) = manifest.files.get(shard_file) {
561                let actual_size = std::fs::metadata(&shard_path).map(|m| m.len()).unwrap_or(0);
562                if actual_size == expected.size {
563                    checksums.insert(
564                        shard_file.to_string(),
565                        FileChecksum {
566                            size: expected.size,
567                            blake3: expected.blake3.clone(),
568                        },
569                    );
570                    println!(
571                        "  {} [{}/{}] {} (cached, verified)",
572                        "✓".green(),
573                        index + 1,
574                        total,
575                        shard_file
576                    );
577                    return Ok(());
578                }
579                println!(
580                    "  {} [{}/{}] {} (size mismatch: {} vs {} bytes, re-downloading)",
581                    "⚠".yellow(),
582                    index + 1,
583                    total,
584                    shard_file,
585                    actual_size,
586                    expected.size
587                );
588                // Fall through to re-download
589            }
590        } else {
591            println!(
592                "  {} [{}/{}] {} (cached)",
593                "✓".green(),
594                index + 1,
595                total,
596                shard_file
597            );
598            return Ok(());
599        }
600    }
601
602    let shard_url = format!("{base_url}/{shard_file}");
603    print!(
604        "  {} [{}/{}] {}...",
605        "↓".yellow(),
606        index + 1,
607        total,
608        shard_file
609    );
610    io::stdout().flush().ok();
611
612    let checksum = download_file_with_progress(&shard_url, &shard_path)?;
613    checksums.insert(shard_file.to_string(), checksum);
614    println!(" {}", "done".green());
615    Ok(())
616}
617
618/// Download companion files (tokenizer, config) for sharded models.
619///
620/// GH-356: tokenizer.json is optional — some models only have tokenizer.model (SentencePiece)
621/// or tokenizer_config.json. We validate that at least ONE tokenizer file was obtained.
622fn download_companion_files(cache_dir: &Path, base_url: &str, force: bool) -> Result<()> {
623    // (filename, is_required) — tokenizer files are individually optional but collectively required
624    let companions = [
625        ("tokenizer.json", false),
626        ("config.json", true),
627        ("tokenizer_config.json", false),
628        ("tokenizer.model", false),
629    ];
630    for (filename, required) in &companions {
631        let companion_path = cache_dir.join(filename);
632        if !force && companion_path.exists() {
633            println!("  {} {} (cached)", "✓".green(), filename);
634            continue;
635        }
636
637        let url = format!("{base_url}/{filename}");
638        match download_file(&url, &companion_path) {
639            Ok(()) => println!("  {} {}", "✓".green(), filename),
640            Err(CliError::HttpNotFound(_)) if *required => {
641                return Err(CliError::ValidationFailed(format!(
642                    "{filename} is required for inference but was not found (HTTP 404) at {url}"
643                )));
644            }
645            Err(CliError::HttpNotFound(_)) => {
646                println!("  {} {} (not found in repo)", "⚠".yellow(), filename);
647            }
648            Err(e) if *required => {
649                return Err(CliError::ValidationFailed(format!(
650                    "{filename} is required for inference but download failed: {e}"
651                )));
652            }
653            Err(_) => println!("  {} {} (not available, optional)", "⚠".yellow(), filename),
654        }
655    }
656
657    // GH-356: Validate at least one tokenizer file exists
658    let tokenizer_files = ["tokenizer.json", "tokenizer.model", "tokenizer_config.json"];
659    let has_tokenizer = tokenizer_files.iter().any(|f| cache_dir.join(f).exists());
660    if !has_tokenizer {
661        return Err(CliError::ValidationFailed(format!(
662            "No tokenizer found for this model. Tried: {}.\n\
663             The model may require a custom tokenizer not hosted in the repository.",
664            tokenizer_files.join(", ")
665        )));
666    }
667
668    Ok(())
669}
670
671/// Write shard manifest with BLAKE3 checksums for integrity verification.
672fn write_shard_manifest(
673    manifest_path: &Path,
674    org: &str,
675    repo: &str,
676    file_checksums: HashMap<String, FileChecksum>,
677) -> Result<()> {
678    if file_checksums.is_empty() {
679        return Ok(());
680    }
681    let manifest = ShardManifest {
682        version: 1,
683        repo: format!("{org}/{repo}"),
684        files: file_checksums,
685    };
686    let manifest_json = serde_json::to_string_pretty(&manifest)
687        .map_err(|e| CliError::ValidationFailed(format!("Failed to serialize manifest: {e}")))?;
688    std::fs::write(manifest_path, manifest_json)?;
689    println!("  {} .apr-manifest.json (integrity checksums)", "✓".green());
690    Ok(())
691}
692
693/// CRUX-A-01 FALSIFY-CRUX-A-01-001: `--dry-run` resolver.
694///
695/// Emits the resolved canonical URL on stdout and returns `Ok(())` with zero
696/// network I/O. Short names are resolved via the embedded alias map
697/// (`configs/aliases.yaml`); scheme-qualified inputs (`hf://…`,
698/// `https://…`) and bare `org/repo` inputs echo as their canonical forms.
699///
700/// CRUX-A-01 FALSIFY-CRUX-A-01-003: unknown short names (no scheme, no `/`)
701/// return an error that includes a Levenshtein ≤ 2 "did you mean …" hint.
702/// CRUX-A-03 ALGO-001..003: `--revision` is classified locally and echoed
703/// in the dry-run output. Malformed revisions (empty, whitespace, URL)
704/// fail fast without touching the network.
705///
706/// CRUX-A-20 ALGO-001..005: the effective offline signal (CLI flag OR
707/// `APR_OFFLINE` OR `HF_HUB_OFFLINE` truthy) is echoed too.
708fn run_dry_run(
709    model_ref: &str,
710    revision: Option<&str>,
711    offline_flag: bool,
712    json: bool,
713) -> Result<()> {
714    let report = build_dry_run_report(model_ref, revision, offline_flag)?;
715    println!("{}", report.stdout(json));
716    Ok(())
717}
718
719/// The facts `apr pull --dry-run` resolves, separated from how they are rendered.
720///
721/// Keeping resolution and rendering apart is what makes `--json` honest: the
722/// exact string written to stdout in JSON mode is [`DryRunReport::to_json`],
723/// so a unit test over that string tests the bytes a consumer will parse.
724#[derive(Debug)]
725pub(crate) struct DryRunReport {
726    pub(crate) model: String,
727    pub(crate) resolved: String,
728    pub(crate) revision: String,
729    pub(crate) revision_kind: String,
730    pub(crate) offline: bool,
731}
732
733impl DryRunReport {
734    /// The complete stdout of `apr pull --dry-run`, in whichever mode was asked
735    /// for. Under `--json` that is exactly one JSON document and nothing else.
736    pub(crate) fn stdout(&self, json: bool) -> String {
737        if json {
738            self.to_json()
739        } else {
740            self.to_human()
741        }
742    }
743
744    // serde_json::json!() uses infallible unwrap internally
745    #[allow(clippy::disallowed_methods)]
746    fn to_json(&self) -> String {
747        let doc = serde_json::json!({
748            "model": self.model,
749            "resolved": self.resolved,
750            "revision": self.revision,
751            "revision_kind": self.revision_kind,
752            "offline": self.offline,
753            "mode": "dry-run",
754        });
755        serde_json::to_string_pretty(&doc).unwrap_or_default()
756    }
757
758    fn to_human(&self) -> String {
759        let offline = if self.offline {
760            "true".green()
761        } else {
762            "false".yellow()
763        };
764        format!(
765            "{}\n\nModel:    {}\nResolved: {}\nRevision: {} ({})\nOffline:  {}\nMode:     {} (no network I/O)",
766            "=== APR Pull ===".cyan().bold(),
767            self.model.cyan(),
768            self.resolved.green(),
769            self.revision.green(),
770            self.revision_kind,
771            offline,
772            "dry-run".yellow(),
773        )
774    }
775}
776
777pub(crate) fn build_dry_run_report(
778    model_ref: &str,
779    revision: Option<&str>,
780    offline_flag: bool,
781) -> Result<DryRunReport> {
782    use super::aliases;
783    use super::offline;
784    use super::revision as rev;
785
786    let resolved = if let Some(url) = aliases::resolve_short_name(model_ref) {
787        url
788    } else if !model_ref.contains("://") && model_ref.contains('/') {
789        format!("hf://{model_ref}")
790    } else {
791        return Err(unknown_short_name_error(model_ref));
792    };
793
794    let rev_spec = revision.unwrap_or(rev::DEFAULT_REVISION);
795    let rev_kind = rev::classify_revision(rev_spec).map_err(|msg| {
796        CliError::ValidationFailed(format!("CRUX-A-03: invalid --revision {rev_spec:?}: {msg}"))
797    })?;
798
799    // CRUX-A-20: resolve offline signal from CLI flag + env vars.
800    let env = offline::read_offline_env();
801    let env_borrowed: Vec<(&str, &str)> =
802        env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
803    let is_offline = offline::is_offline(offline_flag, env_borrowed.iter().copied());
804
805    Ok(DryRunReport {
806        model: model_ref.to_string(),
807        resolved,
808        revision: rev_spec.to_string(),
809        revision_kind: format!("{rev_kind:?}"),
810        offline: is_offline,
811    })
812}
813
814/// CRUX-A-01 FALSIFY-CRUX-A-01-003: build an error carrying a did-you-mean
815/// hint derived from Levenshtein ≤ 2 matches against the alias map.
816fn unknown_short_name_error(name: &str) -> CliError {
817    use super::aliases;
818
819    let suggestions = aliases::did_you_mean(name, 2);
820    let hint = if suggestions.is_empty() {
821        "Run `apr registry aliases --json` to list known short names.".to_string()
822    } else {
823        format!(
824            "did you mean {}? (run `apr registry aliases --json` for the full list)",
825            suggestions
826                .iter()
827                .map(|s| format!("`{s}`"))
828                .collect::<Vec<_>>()
829                .join(", ")
830        )
831    };
832    CliError::ValidationFailed(format!(
833        "CRUX-A-01: unknown short name '{name}' and not a fully-qualified URI. {hint}"
834    ))
835}
836
837include!("pull_list.rs");
838include!("pull_remove_resolve_model.rs");
839include!("pull_extract_shard.rs");
840include!("pull_04.rs");
841include!("pull_dataset.rs");
842
843#[cfg(all(test, feature = "inference"))]
844mod sharded_gguf_interop_tests {
845    use aprender::format::gguf::{
846        export_tensors_to_gguf, merge_gguf_shards, GgmlType, GgufTensor, GgufValue,
847    };
848    use std::path::Path;
849
850    fn write_part(path: &Path, tensors: &[GgufTensor], meta: &[(String, GgufValue)]) {
851        let mut buf = Vec::new();
852        export_tensors_to_gguf(&mut buf, tensors, meta).expect("export part");
853        std::fs::write(path, &buf).expect("write part");
854    }
855
856    /// FT-MERGE-006: the merged sharded GGUF is accepted by realizar's OWN GGUF
857    /// parser (`GGUFModel::from_bytes`) — the actual inference loader, not just
858    /// aprender-core's reader. Closes the cross-parser verification gap: a merge
859    /// validated only by the writer's sibling reader could still be rejected by
860    /// the loader it exists to feed.
861    #[test]
862    fn merged_sharded_gguf_loads_in_realizar() {
863        let dir = std::env::temp_dir().join(format!("apr-merge-interop-{}", std::process::id()));
864        std::fs::create_dir_all(&dir).expect("mkdir");
865        let p0 = dir.join("model-00001-of-00002.gguf");
866        let p1 = dir.join("model-00002-of-00002.gguf");
867        let merged = dir.join("model.gguf");
868
869        let tensor = |name: &str, fill: u8| GgufTensor {
870            name: name.into(),
871            shape: vec![4],
872            dtype: GgmlType::F32,
873            data: vec![fill; 16],
874        };
875        write_part(
876            &p0,
877            &[tensor("blk.0.weight", 1)],
878            &[
879                (
880                    "general.architecture".into(),
881                    GgufValue::String("gemma".into()),
882                ),
883                ("gemma.embedding_length".into(), GgufValue::Uint32(2048)),
884                ("gemma.block_count".into(), GgufValue::Uint32(18)),
885                ("split.no".into(), GgufValue::Uint16(0)),
886                ("split.count".into(), GgufValue::Uint16(2)),
887            ],
888        );
889        write_part(
890            &p1,
891            &[tensor("blk.1.weight", 2)],
892            &[("split.no".into(), GgufValue::Uint16(1))],
893        );
894
895        merge_gguf_shards(&[p0, p1], &merged).expect("merge");
896        let bytes = std::fs::read(&merged).expect("read merged");
897
898        let parsed = realizar::gguf::GGUFModel::from_bytes(&bytes);
899        assert!(
900            parsed.is_ok(),
901            "realizar's GGUF loader must accept the merged sharded file: {:?}",
902            parsed.err()
903        );
904
905        std::fs::remove_dir_all(&dir).ok();
906    }
907}