hexz_cli/cmd/data/
pull.rs1use anyhow::{Context, Result};
4use colored::Colorize;
5use hexz_core::format::header::Header;
6use hexz_core::format::magic::HEADER_SIZE;
7use hexz_store::StorageBackend;
8use hexz_store::local::MmapBackend;
9use hexz_store::remote::{self, RemoteTransport};
10use std::collections::HashSet;
11use std::path::Path;
12
13use super::workspace::Workspace;
14
15pub fn run(remote_name: &str, archive: Option<&str>) -> Result<()> {
17 let ws = Workspace::find(&std::env::current_dir()?)?
18 .context("Not in a hexz workspace (no .hexz found)")?;
19
20 let url = ws.config.remotes.get(remote_name).with_context(|| {
21 format!(
22 "Remote '{remote_name}' not found. Add it with `hexz remote add {remote_name} <url>`"
23 )
24 })?;
25
26 let local_dir = ws
28 .config
29 .host_cwd
30 .clone()
31 .unwrap_or_else(|| ws.root.clone());
32
33 println!(
34 "{} Pulling from {} {}",
35 "╭".dimmed(),
36 remote_name.magenta(),
37 url.bright_black()
38 );
39
40 let transport =
41 remote::connect(url).map_err(|e| anyhow::anyhow!("Failed to connect to remote: {e}"))?;
42
43 let mut pulled = HashSet::new();
44
45 if let Some(name) = archive {
46 pull_archive(name, &local_dir, transport.as_ref(), &mut pulled)?;
48 } else {
49 println!(" {} Listing remote archives...", "→".yellow());
51 let remote_archives = transport
52 .list_archives()
53 .map_err(|e| anyhow::anyhow!("Failed to list remote archives: {e}"))?;
54
55 if remote_archives.is_empty() {
56 println!(" {} No archives on remote.", "·".dimmed());
57 } else {
58 let mut new_count = 0u32;
59 for info in &remote_archives {
60 let local_path = local_dir.join(&info.name);
61 if local_path.exists() {
62 continue;
63 }
64 pull_archive(&info.name, &local_dir, transport.as_ref(), &mut pulled)?;
65 new_count += 1;
66 }
67 if new_count == 0 {
68 println!(" {} Already up to date.", "·".dimmed());
69 }
70 }
71 }
72
73 println!("\n {} Pull complete.", "✓".green());
74 Ok(())
75}
76
77fn pull_archive(
79 name: &str,
80 local_dir: &Path,
81 transport: &dyn RemoteTransport,
82 pulled: &mut HashSet<String>,
83) -> Result<()> {
84 if !pulled.insert(name.to_string()) {
85 return Ok(());
86 }
87
88 let local_path = local_dir.join(name);
89
90 if local_path.exists() {
91 println!(" {} {} (already local)", "·".dimmed(), name.dimmed());
92 } else {
93 println!(" {} Downloading {}...", "→".yellow(), name.cyan());
94 transport
95 .download(name, &local_path)
96 .map_err(|e| anyhow::anyhow!("Download failed: {e}"))?;
97 println!(" {} {}", "✓".green(), name.green());
98 }
99
100 if local_path.exists() {
102 if let Ok(header) = read_header(&local_path) {
103 for parent_path in &header.parent_paths {
104 let parent_name = Path::new(parent_path)
105 .file_name()
106 .map_or_else(|| parent_path.clone(), |f| f.to_string_lossy().to_string());
107
108 let parent_local = local_dir.join(&parent_name);
109 if !parent_local.exists() {
110 let on_remote = transport
112 .exists(&parent_name)
113 .map_err(|e| anyhow::anyhow!("Failed to check remote: {e}"))?;
114 if on_remote {
115 pull_archive(&parent_name, local_dir, transport, pulled)?;
116 } else {
117 eprintln!(
118 " {} Parent not found on remote: {} (skipping)",
119 "!".yellow(),
120 parent_name
121 );
122 }
123 }
124 }
125 }
126 }
127
128 Ok(())
129}
130
131fn read_header(path: &Path) -> Result<Header> {
133 let backend = MmapBackend::new(path)
134 .map_err(|e| anyhow::anyhow!("Cannot open archive {}: {e}", path.display()))?;
135 let header_bytes = backend
136 .read_exact(0, HEADER_SIZE)
137 .map_err(|e| anyhow::anyhow!("Cannot read header: {e}"))?;
138 let header: Header = bincode::deserialize(&header_bytes).context("Invalid archive header")?;
139 Ok(header)
140}