Skip to main content

hexz_cli/cmd/data/
push.rs

1//! Push archives to 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, PathBuf};
12
13use super::workspace::Workspace;
14
15/// Execute the `hexz push` command to upload archives to a remote.
16pub fn run(remote_name: &str, archive: Option<PathBuf>) -> 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 target = if let Some(a) = archive {
27        a
28    } else if let Some(b) = &ws.config.base_archive {
29        b.clone()
30    } else {
31        anyhow::bail!("No archive specified and workspace has no base archive to push.");
32    };
33
34    if !target.exists() {
35        anyhow::bail!("Archive not found: {}", target.display());
36    }
37
38    println!(
39        "{} Pushing to    {} {}",
40        "╭".dimmed(),
41        remote_name.magenta(),
42        url.bright_black()
43    );
44
45    let transport =
46        remote::connect(url).map_err(|e| anyhow::anyhow!("Failed to connect to remote: {e}"))?;
47
48    let mut pushed = HashSet::new();
49    push_archive(&target, transport.as_ref(), &mut pushed)?;
50
51    println!("\n  {} Push complete.", "✓".green());
52    Ok(())
53}
54
55/// Recursively push an archive and any parents missing from the remote.
56fn push_archive(
57    path: &Path,
58    transport: &dyn RemoteTransport,
59    pushed: &mut HashSet<PathBuf>,
60) -> Result<()> {
61    let canonical = std::fs::canonicalize(path)
62        .with_context(|| format!("Cannot resolve archive path: {}", path.display()))?;
63
64    if !pushed.insert(canonical.clone()) {
65        return Ok(());
66    }
67
68    let name = canonical
69        .file_name()
70        .context("Archive path has no file name")?
71        .to_string_lossy()
72        .to_string();
73
74    // Check if already on remote
75    let already_exists = transport
76        .exists(&name)
77        .map_err(|e| anyhow::anyhow!("Failed to check remote: {e}"))?;
78
79    if already_exists {
80        println!("  {} {} (already on remote)", "·".dimmed(), name.dimmed());
81    } else {
82        // Read header to find parents before uploading
83        let header = read_header(&canonical)?;
84
85        // Push parents first (so remote always has a complete chain)
86        let archive_dir = canonical.parent().unwrap_or_else(|| Path::new("."));
87        for parent_path in &header.parent_paths {
88            let parent = resolve_parent(archive_dir, parent_path);
89            if parent.exists() {
90                push_archive(&parent, transport, pushed)?;
91            } else {
92                eprintln!(
93                    "  {} Parent not found locally: {} (skipping)",
94                    "!".yellow(),
95                    parent_path
96                );
97            }
98        }
99
100        println!("  {} Uploading {}...", "→".yellow(), name.cyan());
101        transport
102            .upload(&canonical, &name)
103            .map_err(|e| anyhow::anyhow!("Upload failed: {e}"))?;
104        println!("  {} {}", "✓".green(), name.green());
105    }
106
107    Ok(())
108}
109
110/// Read just the header from a local archive file.
111fn read_header(path: &Path) -> Result<Header> {
112    let backend = MmapBackend::new(path)
113        .map_err(|e| anyhow::anyhow!("Cannot open archive {}: {e}", path.display()))?;
114    let header_bytes = backend
115        .read_exact(0, HEADER_SIZE)
116        .map_err(|e| anyhow::anyhow!("Cannot read header: {e}"))?;
117    let header: Header = bincode::deserialize(&header_bytes).context("Invalid archive header")?;
118    Ok(header)
119}
120
121/// Resolve a parent path relative to the archive's directory.
122fn resolve_parent(archive_dir: &Path, parent_path: &str) -> PathBuf {
123    let p = Path::new(parent_path);
124    if p.is_absolute() && p.exists() {
125        return p.to_path_buf();
126    }
127    let rel = archive_dir.join(parent_path);
128    if rel.exists() {
129        return rel;
130    }
131    p.to_path_buf()
132}