zernel/commands/
hub.rs

1// Copyright (C) 2026 Dyber, Inc. — Proprietary
2
3//! zernel hub — Private model & dataset hub
4
5use anyhow::Result;
6use clap::Subcommand;
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9
10#[derive(Subcommand)]
11pub enum HubCommands {
12    /// Push a model or dataset to the hub
13    Push {
14        /// Local path to model/dataset
15        path: String,
16        /// Hub name (org/name)
17        #[arg(long)]
18        name: String,
19        /// Version tag
20        #[arg(long, default_value = "latest")]
21        tag: String,
22    },
23    /// Pull a model or dataset from the hub
24    Pull {
25        /// Hub name (org/name:tag)
26        name: String,
27        /// Destination path
28        #[arg(long, default_value = ".")]
29        to: String,
30    },
31    /// List all items in the hub
32    List,
33    /// Search the hub
34    Search {
35        /// Search query
36        query: String,
37    },
38    /// Start a local hub server
39    Serve {
40        /// Port
41        #[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            // Copy
114            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}