1use anyhow::Result;
6use clap::Subcommand;
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9
10#[derive(Subcommand)]
11pub enum HubCommands {
12 Push {
14 path: String,
16 #[arg(long)]
18 name: String,
19 #[arg(long, default_value = "latest")]
21 tag: String,
22 },
23 Pull {
25 name: String,
27 #[arg(long, default_value = ".")]
29 to: String,
30 },
31 List,
33 Search {
35 query: String,
37 },
38 Serve {
40 #[arg(long, default_value = "9999")]
42 port: u16,
43 },
44}
45
46#[derive(Debug, Serialize, Deserialize)]
47struct HubEntry {
48 name: String,
49 tag: String,
50 path: String,
51 size_bytes: u64,
52 pushed_at: String,
53}
54
55fn hub_dir() -> PathBuf {
56 let dir = crate::experiments::tracker::zernel_dir().join("hub");
57 std::fs::create_dir_all(&dir).ok();
58 dir
59}
60
61fn hub_registry() -> PathBuf {
62 hub_dir().join("registry.json")
63}
64
65fn load_hub() -> Vec<HubEntry> {
66 hub_registry()
67 .exists()
68 .then(|| {
69 std::fs::read_to_string(hub_registry())
70 .ok()
71 .and_then(|s| serde_json::from_str(&s).ok())
72 })
73 .flatten()
74 .unwrap_or_default()
75}
76
77fn save_hub(entries: &[HubEntry]) -> Result<()> {
78 std::fs::write(hub_registry(), serde_json::to_string_pretty(entries)?)?;
79 Ok(())
80}
81
82fn dir_size(path: &std::path::Path) -> u64 {
83 if path.is_file() {
84 return path.metadata().map(|m| m.len()).unwrap_or(0);
85 }
86 let mut total = 0u64;
87 if let Ok(entries) = std::fs::read_dir(path) {
88 for entry in entries.flatten() {
89 let ft = entry.file_type().ok();
90 if ft.as_ref().map(|t| t.is_file()).unwrap_or(false) {
91 total += entry.metadata().map(|m| m.len()).unwrap_or(0);
92 } else if ft.as_ref().map(|t| t.is_dir()).unwrap_or(false) {
93 total += dir_size(&entry.path());
94 }
95 }
96 }
97 total
98}
99
100pub async fn run(cmd: HubCommands) -> Result<()> {
101 match cmd {
102 HubCommands::Push { path, name, tag } => {
103 let source = std::path::Path::new(&path);
104 if !source.exists() {
105 anyhow::bail!("path not found: {path}");
106 }
107
108 let dest = hub_dir().join(&name).join(&tag);
109 std::fs::create_dir_all(&dest)?;
110
111 println!("Pushing {path} → hub/{name}:{tag}");
112
113 if source.is_file() {
115 let fname = source.file_name().unwrap_or_default();
116 std::fs::copy(source, dest.join(fname))?;
117 } else {
118 let status = std::process::Command::new("cp")
119 .args(["-r", &path, &dest.to_string_lossy()])
120 .status()?;
121 let _ = status;
122 }
123
124 let size = dir_size(&dest);
125
126 let mut hub = load_hub();
127 hub.retain(|e| !(e.name == name && e.tag == tag));
128 hub.push(HubEntry {
129 name: name.clone(),
130 tag: tag.clone(),
131 path: dest.to_string_lossy().to_string(),
132 size_bytes: size,
133 pushed_at: chrono::Utc::now().to_rfc3339(),
134 });
135 save_hub(&hub)?;
136
137 println!(
138 "Pushed: {name}:{tag} ({:.1} MB)",
139 size as f64 / (1024.0 * 1024.0)
140 );
141 }
142
143 HubCommands::Pull { name, to } => {
144 let (hub_name, tag) = name.split_once(':').unwrap_or((&name, "latest"));
145 let hub = load_hub();
146 let entry = hub
147 .iter()
148 .find(|e| e.name == hub_name && e.tag == tag)
149 .ok_or_else(|| anyhow::anyhow!("not found in hub: {hub_name}:{tag}"))?;
150
151 println!("Pulling {hub_name}:{tag} → {to}");
152 let status = std::process::Command::new("cp")
153 .args(["-r", &entry.path, &to])
154 .status()?;
155
156 if status.success() {
157 println!("Done.");
158 }
159 }
160
161 HubCommands::List => {
162 let hub = load_hub();
163 if hub.is_empty() {
164 println!(
165 "Hub is empty. Push something: zernel hub push ./model --name my-org/model"
166 );
167 return Ok(());
168 }
169
170 let hdr = format!(
171 "{:<30} {:<10} {:>10} {:>10}",
172 "Name", "Tag", "Size", "Pushed"
173 );
174 println!("{hdr}");
175 println!("{}", "-".repeat(65));
176 for e in &hub {
177 println!(
178 "{:<30} {:<10} {:>8.1} MB {}",
179 e.name,
180 e.tag,
181 e.size_bytes as f64 / (1024.0 * 1024.0),
182 &e.pushed_at[..10]
183 );
184 }
185 }
186
187 HubCommands::Search { query } => {
188 let hub = load_hub();
189 let q = query.to_lowercase();
190 let results: Vec<_> = hub
191 .iter()
192 .filter(|e| e.name.to_lowercase().contains(&q))
193 .collect();
194
195 if results.is_empty() {
196 println!("No results for '{query}'");
197 } else {
198 for e in results {
199 println!(
200 "{}:{} ({:.1} MB)",
201 e.name,
202 e.tag,
203 e.size_bytes as f64 / (1024.0 * 1024.0)
204 );
205 }
206 }
207 }
208
209 HubCommands::Serve { port } => {
210 println!("Starting Zernel Hub server on port {port}...");
211 println!("URL: http://0.0.0.0:{port}");
212 println!("(Hub server coming in future release — for now, use zernel hub push/pull)");
213 }
214 }
215 Ok(())
216}