Skip to main content

hexz_cli/cmd/data/
pull.rs

1//! Pull archives from a remote endpoint.
2
3use 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
15/// Execute the `hexz pull` command to fetch archives from a remote.
16pub 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    // Determine the local directory where archives are stored
27    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 a specific archive
47        pull_archive(name, &local_dir, transport.as_ref(), &mut pulled)?;
48    } else {
49        // Pull all archives not present locally
50        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
77/// Download an archive and recursively pull any missing parents.
78fn 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    // Read header to discover parents
101    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                    // Check if parent is on remote before trying to pull
111                    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
131/// Read just the header from a local archive file.
132fn 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}