Skip to main content

apr_cli/commands/
pull_list.rs

1
2/// Scan the pacha cache directory for model files that are on disk but may not be
3/// tracked in the manifest (e.g., downloaded before pacha GH-162 added manifest
4/// persistence, or via direct writes outside the fetcher).
5///
6/// Contract: apr-list-disk-reconciliation-v1 F-LIST-DISK-001 (paiml/aprender#602).
7fn scan_cache_dir_for_orphans(
8    cache_dir: &Path,
9    known_paths: &HashSet<std::path::PathBuf>,
10) -> Vec<DiskModelEntry> {
11    let Ok(read_dir) = std::fs::read_dir(cache_dir) else {
12        return Vec::new();
13    };
14    let mut orphans = Vec::new();
15    for entry in read_dir.flatten() {
16        let path = entry.path();
17        if !path.is_file() {
18            continue;
19        }
20        if known_paths.contains(&path) {
21            continue;
22        }
23        let Some(ext) = path.extension().and_then(|s| s.to_str()) else {
24            continue;
25        };
26        let format = match ext {
27            "gguf" | "ggml" => "GGUF",
28            "apr" => "APR",
29            "safetensors" => "SafeTensors",
30            _ => continue,
31        };
32        let size_bytes = entry.metadata().map(|m| m.len()).unwrap_or(0);
33        let name = path
34            .file_stem()
35            .and_then(|s| s.to_str())
36            .unwrap_or("unknown")
37            .to_string();
38        orphans.push(DiskModelEntry {
39            name,
40            size_bytes,
41            format,
42            path: path.clone(),
43        });
44    }
45    orphans
46}
47
48struct DiskModelEntry {
49    name: String,
50    size_bytes: u64,
51    format: &'static str,
52    path: std::path::PathBuf,
53}
54
55/// List cached models
56///
57/// Contract: apr-list-quiet-wiring-v1 F-LIST-QUIET-001 (paiml/aprender#623).
58/// When quiet=true, suppress help text and tabular decoration — emit one entry per line.
59// serde_json::json!() macro uses infallible unwrap internally
60#[allow(clippy::disallowed_methods)]
61pub fn list(json: bool, quiet: bool) -> Result<()> {
62    let fetcher = ModelFetcher::new().map_err(|e| {
63        CliError::ValidationFailed(format!("Failed to initialize model fetcher: {e}"))
64    })?;
65
66    let models = fetcher.list();
67
68    // Contract: apr-list-disk-reconciliation-v1 F-LIST-DISK-001 (paiml/aprender#602).
69    // pacha's manifest may be missing or stale (e.g., downloads predating GH-162's
70    // save_manifest fix). Augment with a disk scan of the cache dir so orphan files
71    // are visible. Manifest entries take precedence; only files not already listed
72    // are added as disk orphans.
73    let known_paths: HashSet<std::path::PathBuf> =
74        models.iter().map(|m| m.path.clone()).collect();
75    let orphans = scan_cache_dir_for_orphans(fetcher.cache_dir(), &known_paths);
76
77    // Contract: apr-list-quiet-wiring-v1 F-LIST-QUIET-001 (paiml/aprender#623).
78    // Quiet mode: one identifier per line, no decoration, no help text.
79    if quiet {
80        for m in &models {
81            println!("{}", m.name);
82        }
83        for o in &orphans {
84            println!("{}", o.name);
85        }
86        return Ok(());
87    }
88
89    // GH-248: JSON output mode
90    if json {
91        let mut models_json: Vec<serde_json::Value> = models
92            .iter()
93            .map(|m| {
94                serde_json::json!({
95                    "name": m.name,
96                    "size_bytes": m.size_bytes,
97                    "format": m.format.name(),
98                    "path": m.path.display().to_string(),
99                    "source": "manifest",
100                })
101            })
102            .collect();
103        // Contract: apr-list-disk-reconciliation-v1 F-LIST-DISK-001 (paiml/aprender#602).
104        for o in &orphans {
105            models_json.push(serde_json::json!({
106                "name": o.name,
107                "size_bytes": o.size_bytes,
108                "format": o.format,
109                "path": o.path.display().to_string(),
110                "source": "disk_scan",
111            }));
112        }
113        let stats = fetcher.stats();
114        let orphan_bytes: u64 = orphans.iter().map(|o| o.size_bytes).sum();
115        let output = serde_json::json!({
116            "models": models_json,
117            "total": models.len() + orphans.len(),
118            "total_size_bytes": stats.total_size_bytes + orphan_bytes,
119        });
120        println!(
121            "{}",
122            serde_json::to_string_pretty(&output).unwrap_or_default()
123        );
124        return Ok(());
125    }
126
127    println!("{}", "=== Cached Models ===".cyan().bold());
128    println!();
129
130    if models.is_empty() && orphans.is_empty() {
131        println!("{}", "No cached models found.".dimmed());
132        println!();
133        println!("Pull a model with:");
134        println!("  apr pull hf://Qwen/Qwen2.5-Coder-1.5B-Instruct-GGUF/qwen2.5-coder-1.5b-instruct-q4_k_m.gguf");
135        println!();
136        println!("Or run directly (auto-downloads):");
137        println!("  apr run hf://Qwen/Qwen2.5-Coder-1.5B-Instruct-GGUF/qwen2.5-coder-1.5b-instruct-q4_k_m.gguf");
138        return Ok(());
139    }
140
141    // Print header
142    println!(
143        "{:<40} {:<12} {:<12} {}",
144        "NAME".dimmed(),
145        "SIZE".dimmed(),
146        "FORMAT".dimmed(),
147        "PATH".dimmed()
148    );
149    println!("{}", "-".repeat(104).dimmed());
150
151    for model in &models {
152        let size = format_bytes(model.size_bytes);
153        let format = model.format.name();
154        let name = if model.name.len() > 38 {
155            format!("{}...", &model.name[..35])
156        } else {
157            model.name.clone()
158        };
159
160        println!(
161            "{:<40} {:<12} {:<12} {}",
162            name.cyan(),
163            size.yellow(),
164            format,
165            model.path.display().to_string().dimmed()
166        );
167    }
168
169    // Contract: apr-list-disk-reconciliation-v1 F-LIST-DISK-001 (paiml/aprender#602).
170    for o in &orphans {
171        let size = format_bytes(o.size_bytes);
172        let name = if o.name.len() > 38 {
173            format!("{}...", &o.name[..35])
174        } else {
175            o.name.clone()
176        };
177        println!(
178            "{:<40} {:<12} {:<12} {} {}",
179            name.cyan(),
180            size.yellow(),
181            o.format,
182            o.path.display().to_string().dimmed(),
183            "(orphan)".dimmed()
184        );
185    }
186
187    println!();
188
189    // Print stats
190    let stats = fetcher.stats();
191    let orphan_bytes: u64 = orphans.iter().map(|o| o.size_bytes).sum();
192    let total_count = models.len() + orphans.len();
193    let total_bytes = stats.total_size_bytes + orphan_bytes;
194    if orphans.is_empty() {
195        println!("Total: {} models, {} used", total_count, format_bytes(total_bytes));
196    } else {
197        println!(
198            "Total: {} models ({} tracked + {} orphans), {} used",
199            total_count,
200            models.len(),
201            orphans.len(),
202            format_bytes(total_bytes)
203        );
204    }
205
206    Ok(())
207}