use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use flate2::write::GzEncoder;
use flate2::Compression;
use ignore::WalkBuilder;
use owo_colors::OwoColorize;
use crate::api::client::ApiClient;
use crate::commands::deploy;
use crate::output::{self, Format};
use crate::render::theme;
const LIMIT_BYTES: u64 = 100 * 1024 * 1024;
const ALWAYS_EXCLUDED: [&str; 4] = [".git", ".hg", "node_modules", ".venv"];
pub struct Packed {
pub bytes: Vec<u8>,
pub files: usize,
}
pub async fn run(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
directory: &Path,
deploy_it: bool,
follow: bool,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let packed = pack(directory)?;
let name = archive_name(directory);
if !format.is_json() {
println!();
println!(
" {} {} {} — {} archivos, {}",
theme::ARROW.style(theme::accent()),
project.name.style(theme::muted()),
service.name.style(theme::strong()),
packed.files.style(theme::strong()),
human_size(packed.bytes.len() as u64).style(theme::strong()),
);
println!();
}
let accepted = client
.upload(&service.id, packed.bytes, &name, deploy_it)
.await?;
let Some(deployment) = accepted.deployment else {
if format.is_json() {
output::emit(&accepted.upload);
} else {
println!(
" {} Código guardado. Despliégalo con: fundaia deploy {} {}",
theme::CHECK.style(theme::success()),
project.slug,
service.slug,
);
}
return Ok(());
};
if format.is_json() {
if !follow {
output::emit(&deployment);
return Ok(());
}
return deploy::watch_json(client, &deployment).await;
}
if !follow {
println!(
" {}",
format!(
"Despliegue #{} en marcha. Síguelo con: fundaia logs {} {}",
deployment.number, project.slug, service.slug
)
.style(theme::muted()),
);
return Ok(());
}
deploy::watch(client, &deployment).await
}
pub async fn list(
client: &ApiClient,
project_reference: &str,
service_reference: &str,
format: Format,
) -> Result<()> {
let project = client.find_project(project_reference).await?;
let service = client.find_service(&project.id, service_reference).await?;
let page = client.uploads(&service.id).await?;
if format.is_json() {
output::emit(&page);
return Ok(());
}
if page.uploads.is_empty() {
println!();
println!(
" {}",
"Este servicio no tiene código todavía. Súbelo con `fundaia up`.".style(theme::muted()),
);
println!();
return Ok(());
}
println!();
for upload in &page.uploads {
let current = page.current.as_deref() == Some(upload.id.as_str());
let mark = if current {
theme::CHECK.style(theme::success()).to_string()
} else {
" ".to_owned()
};
let digest: String = upload.digest.chars().take(12).collect();
println!(
" {} {} {} {}",
mark,
upload.file_name.style(theme::strong()),
human_size(upload.size_bytes).style(theme::muted()),
digest.style(theme::muted()),
);
}
println!();
Ok(())
}
pub fn pack(directory: &Path) -> Result<Packed> {
if !directory.is_dir() {
bail!("{} is not a directory", directory.display());
}
let encoder = GzEncoder::new(Vec::new(), Compression::default());
let mut archive = tar::Builder::new(encoder);
let mut files = 0usize;
let mut bytes = 0u64;
let mut walker = WalkBuilder::new(directory);
WalkBuilder::hidden(&mut walker, false);
walker.git_ignore(true);
walker.git_global(true);
walker.git_exclude(true);
walker.add_custom_ignore_filename(".fundaiaignore");
walker.filter_entry(|entry: &ignore::DirEntry| {
!ALWAYS_EXCLUDED.contains(&entry.file_name().to_string_lossy().as_ref())
});
for entry in walker.build() {
let entry = entry.context("could not read the directory")?;
if !entry.file_type().is_some_and(|kind| kind.is_file()) {
continue;
}
let relative = entry
.path()
.strip_prefix(directory)
.context("a file walked out of the directory being packed")?;
bytes += entry.metadata().map(|meta| meta.len()).unwrap_or(0);
if bytes > LIMIT_BYTES {
bail!(
"the code is larger than {} — leave out what the build does not need with a .fundaiaignore",
human_size(LIMIT_BYTES)
);
}
archive
.append_path_with_name(entry.path(), relative)
.with_context(|| format!("could not pack {}", relative.display()))?;
files += 1;
}
if files == 0 {
bail!("there is nothing to upload in {}", directory.display());
}
let encoder = archive
.into_inner()
.context("could not finish the archive")?;
let bytes = encoder.finish().context("could not compress the archive")?;
Ok(Packed { bytes, files })
}
pub fn archive_name(directory: &Path) -> String {
let absolute = directory
.canonicalize()
.unwrap_or_else(|_| PathBuf::from(directory));
let stem = absolute
.file_name()
.map(|name| name.to_string_lossy().to_string())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| "código".to_owned());
format!("{stem}.tar.gz")
}
pub fn human_size(bytes: u64) -> String {
const KIB: f64 = 1024.0;
let value = bytes as f64;
if value < KIB {
return format!("{bytes} B");
}
if value < KIB * KIB {
return format!("{:.0} KB", value / KIB);
}
format!("{:.1} MB", value / (KIB * KIB))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn project() -> tempdir::Directory {
let directory = tempdir::Directory::new();
fs::write(directory.path().join("package.json"), "{}").unwrap();
fs::write(directory.path().join(".gitignore"), "secret.txt\n").unwrap();
fs::write(directory.path().join("secret.txt"), "shhh").unwrap();
fs::create_dir(directory.path().join(".git")).unwrap();
fs::write(directory.path().join(".git").join("HEAD"), "ref: x").unwrap();
fs::create_dir(directory.path().join("node_modules")).unwrap();
fs::write(directory.path().join("node_modules").join("a.js"), "x").unwrap();
directory
}
fn packed_names(directory: &Path) -> Vec<String> {
let packed = pack(directory).expect("the directory packs");
let decoder = flate2::read::GzDecoder::new(packed.bytes.as_slice());
let mut archive = tar::Archive::new(decoder);
archive
.entries()
.expect("the archive can be read")
.map(|entry| {
entry
.expect("an entry")
.path()
.expect("a path")
.to_string_lossy()
.to_string()
})
.collect()
}
#[test]
fn it_should_pack_the_files_a_build_needs() {
let directory = project();
assert!(packed_names(directory.path()).contains(&"package.json".to_owned()));
}
#[test]
fn it_should_leave_out_what_gitignore_excludes() {
let directory = project();
assert!(!packed_names(directory.path()).contains(&"secret.txt".to_owned()));
}
#[test]
fn it_should_leave_out_the_git_directory() {
let directory = project();
assert!(!packed_names(directory.path())
.iter()
.any(|name| name.starts_with(".git/")));
}
#[test]
fn it_should_leave_out_node_modules() {
let directory = project();
assert!(!packed_names(directory.path())
.iter()
.any(|name| name.starts_with("node_modules/")));
}
#[test]
fn it_should_keep_a_hidden_file_a_build_reads() {
let directory = project();
fs::write(directory.path().join(".npmrc"), "registry=x").unwrap();
assert!(packed_names(directory.path()).contains(&".npmrc".to_owned()));
}
#[test]
fn it_should_refuse_a_directory_with_nothing_in_it() {
let directory = tempdir::Directory::new();
assert!(pack(directory.path()).is_err());
}
#[test]
fn it_should_name_the_archive_after_the_directory() {
let directory = tempdir::Directory::new();
assert!(archive_name(directory.path()).ends_with(".tar.gz"));
}
#[test]
fn it_should_print_megabytes_once_there_are_any() {
assert_eq!(human_size(3 * 1024 * 1024), "3.0 MB");
}
#[test]
fn it_should_print_plain_bytes_below_a_kilobyte() {
assert_eq!(human_size(512), "512 B");
}
mod tempdir {
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
pub struct Directory(PathBuf);
impl Directory {
pub fn new() -> Self {
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir()
.join(format!("fundaia-up-{}-{unique}", std::process::id()));
std::fs::create_dir_all(&path).expect("a temporary directory");
Self(path)
}
pub fn path(&self) -> &Path {
&self.0
}
}
impl Drop for Directory {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
}
}