ferrum_cli/commands/
list.rs1use crate::config::CliConfig;
4use clap::Args;
5use colored::*;
6use ferrum_types::Result;
7use std::fs;
8use std::path::PathBuf;
9
10#[derive(Args)]
11pub struct ListCommand {}
12
13pub async fn execute(_cmd: ListCommand, config: CliConfig) -> Result<()> {
14 let cache_dir = get_hf_cache_dir(&config);
15 let hub_dir = cache_dir.join("hub");
16
17 if !hub_dir.exists() {
18 println!("{}", "No models downloaded yet.".dimmed());
19 println!();
20 println!("Run {} to download a model.", "ferrum pull <model>".cyan());
21 return Ok(());
22 }
23
24 let mut models: Vec<ModelInfo> = Vec::new();
25
26 if let Ok(entries) = fs::read_dir(&hub_dir) {
28 for entry in entries.flatten() {
29 let name = entry.file_name().to_string_lossy().to_string();
30 if name.starts_with("models--") {
31 if let Some(info) = get_model_info(&entry.path()) {
32 models.push(info);
33 }
34 }
35 }
36 }
37
38 if models.is_empty() {
39 println!("{}", "No models downloaded yet.".dimmed());
40 println!();
41 println!("Run {} to download a model.", "ferrum pull <model>".cyan());
42 return Ok(());
43 }
44
45 models.sort_by(|a, b| match (a.is_complete, b.is_complete) {
47 (true, false) => std::cmp::Ordering::Less,
48 (false, true) => std::cmp::Ordering::Greater,
49 _ => a.name.cmp(&b.name),
50 });
51
52 println!(
54 "{:<40} {:<12} {:<10} {:<16}",
55 "NAME".bold(),
56 "SIZE".bold(),
57 "STATUS".bold(),
58 "MODIFIED".bold()
59 );
60
61 for model in models {
63 let status = if model.is_complete {
64 "ready".green().to_string()
65 } else {
66 "incomplete".yellow().to_string()
67 };
68
69 let name_display = if model.is_complete {
70 model.name.normal().to_string()
71 } else {
72 model.name.dimmed().to_string()
73 };
74
75 println!(
76 "{:<40} {:<12} {:<10} {:<16}",
77 name_display,
78 format_size(model.size),
79 status,
80 model.modified
81 );
82 }
83
84 Ok(())
85}
86
87struct ModelInfo {
88 name: String,
89 size: u64,
90 modified: String,
91 is_complete: bool,
92}
93
94fn get_model_info(model_dir: &PathBuf) -> Option<ModelInfo> {
95 let dir_name = model_dir.file_name()?.to_string_lossy().to_string();
97 let name = dir_name.strip_prefix("models--")?.replace("--", "/");
98
99 let blobs_dir = model_dir.join("blobs");
101 let size = if blobs_dir.exists() {
102 get_dir_size(&blobs_dir)
103 } else {
104 0
105 };
106
107 let snapshots_dir = model_dir.join("snapshots");
109 let is_complete = check_model_complete(&snapshots_dir);
110
111 let modified = if let Ok(metadata) = fs::metadata(model_dir) {
113 if let Ok(time) = metadata.modified() {
114 let datetime: chrono::DateTime<chrono::Local> = time.into();
115 datetime.format("%Y-%m-%d %H:%M").to_string()
116 } else {
117 "unknown".to_string()
118 }
119 } else {
120 "unknown".to_string()
121 };
122
123 Some(ModelInfo {
124 name,
125 size,
126 modified,
127 is_complete,
128 })
129}
130
131fn check_model_complete(snapshots_dir: &PathBuf) -> bool {
133 if !snapshots_dir.exists() {
134 return false;
135 }
136
137 if let Ok(entries) = fs::read_dir(snapshots_dir) {
139 for entry in entries.flatten() {
140 let path = entry.path();
141 if path.is_dir() {
142 let has_weights = fs::read_dir(&path)
145 .is_ok_and(|files| files.flatten().any(|file| is_weight_file(&file.path())));
146 if has_weights {
147 return true;
148 }
149 }
150 }
151 }
152
153 false
154}
155
156fn is_weight_file(path: &std::path::Path) -> bool {
157 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
158 return false;
159 };
160 let lower = name.to_ascii_lowercase();
161 lower.ends_with(".safetensors")
162 || lower.ends_with(".safetensors.index.json")
163 || lower.ends_with(".gguf")
164 || (lower.starts_with("pytorch_model")
165 && (lower.ends_with(".bin") || lower.ends_with(".bin.index.json")))
166}
167
168fn get_dir_size(path: &PathBuf) -> u64 {
169 let mut size = 0;
170 if let Ok(entries) = fs::read_dir(path) {
171 for entry in entries.flatten() {
172 let path = entry.path();
173 if path.is_file() {
174 if let Ok(metadata) = fs::metadata(&path) {
175 size += metadata.len();
176 }
177 } else if path.is_dir() {
178 size += get_dir_size(&path);
179 }
180 }
181 }
182 size
183}
184
185fn format_size(bytes: u64) -> String {
186 const KB: u64 = 1024;
187 const MB: u64 = KB * 1024;
188 const GB: u64 = MB * 1024;
189
190 if bytes >= GB {
191 format!("{:.1} GB", bytes as f64 / GB as f64)
192 } else if bytes >= MB {
193 format!("{:.1} MB", bytes as f64 / MB as f64)
194 } else if bytes >= KB {
195 format!("{:.1} KB", bytes as f64 / KB as f64)
196 } else {
197 format!("{} B", bytes)
198 }
199}
200
201fn get_hf_cache_dir(config: &CliConfig) -> PathBuf {
202 if let Ok(hf_home) = std::env::var("HF_HOME") {
203 return PathBuf::from(hf_home);
204 }
205 let configured = shellexpand::tilde(&config.models.download.hf_cache_dir).to_string();
206 PathBuf::from(configured)
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 fn temporary_snapshot(name: &str) -> PathBuf {
214 let nonce = std::time::SystemTime::now()
215 .duration_since(std::time::UNIX_EPOCH)
216 .unwrap()
217 .as_nanos();
218 let root =
219 std::env::temp_dir().join(format!("ferrum-list-{name}-{}-{nonce}", std::process::id()));
220 fs::create_dir_all(root.join("snapshots/revision")).unwrap();
221 root
222 }
223
224 #[test]
225 fn gguf_snapshot_is_complete() {
226 let root = temporary_snapshot("gguf");
227 fs::write(
228 root.join("snapshots/revision/Qwen3.5-4B-Q4_K_M.gguf"),
229 b"weight",
230 )
231 .unwrap();
232
233 assert!(check_model_complete(&root.join("snapshots")));
234 fs::remove_dir_all(root).unwrap();
235 }
236
237 #[test]
238 fn tokenizer_only_snapshot_is_incomplete() {
239 let root = temporary_snapshot("tokenizer");
240 fs::write(root.join("snapshots/revision/tokenizer.json"), b"{}").unwrap();
241
242 assert!(!check_model_complete(&root.join("snapshots")));
243 fs::remove_dir_all(root).unwrap();
244 }
245}