use std::path::PathBuf;
use std::process::Command;
use anyhow::{Result, bail};
use caixa_core::{Caixa, DEFAULT_GIT_REMOTE, DEFAULT_PUBLISH_TAG_PREFIX};
use clap::Args;
use super::load::{caixa_root, load_caixa};
#[derive(Args)]
pub struct Publish {
#[arg(long)]
pub versao: Option<String>,
#[arg(long, default_value = DEFAULT_PUBLISH_TAG_PREFIX)]
pub prefix: String,
#[arg(long, default_value = DEFAULT_GIT_REMOTE)]
pub remote: String,
#[arg(long)]
pub no_push: bool,
#[arg(long)]
pub path: Option<PathBuf>,
}
impl Publish {
pub fn run(self) -> Result<()> {
let root = caixa_root(self.path.as_deref());
let caixa = load_caixa(&root)?;
let versao = publish_effective_versao(self.versao.as_deref(), &caixa);
let tag = format!("{}{versao}", self.prefix);
let status = run_git(&root, ["status", "--porcelain"])?;
if !status.trim().is_empty() {
bail!("working tree is dirty — commit or stash first:\n{status}");
}
let msg = publish_tag_message(&caixa, &tag);
exec_git(&root, ["tag", "-a", &tag, "-m", &msg])?;
if !self.no_push {
exec_git(&root, ["push", &self.remote, &tag])?;
eprintln!("published {tag} to {}", self.remote);
} else {
eprintln!("created tag {tag} locally (not pushed)");
}
Ok(())
}
}
pub(crate) fn publish_effective_versao(cli_versao: Option<&str>, caixa: &Caixa) -> String {
cli_versao
.map(str::to_string)
.unwrap_or_else(|| caixa.versao().to_string())
}
pub(crate) fn publish_tag_message(caixa: &Caixa, tag: &str) -> String {
format!("caixa {} {tag}", caixa.nome())
}
fn exec_git<'a, I: IntoIterator<Item = &'a str>>(cwd: &std::path::Path, args: I) -> Result<()> {
let out = Command::new("git")
.current_dir(cwd)
.args(args.into_iter().collect::<Vec<_>>())
.output()?;
if !out.status.success() {
bail!("git failed: {}", String::from_utf8_lossy(&out.stderr));
}
Ok(())
}
fn run_git<'a, I: IntoIterator<Item = &'a str>>(cwd: &std::path::Path, args: I) -> Result<String> {
let out = Command::new("git")
.current_dir(cwd)
.args(args.into_iter().collect::<Vec<_>>())
.output()?;
if !out.status.success() {
bail!("git failed: {}", String::from_utf8_lossy(&out.stderr));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{Args as _, FromArgMatches};
#[test]
fn publish_prefix_default_pins_lifted_caixa_core_constant() {
let cmd = Publish::augment_args(clap::Command::new("publish"));
let matches = cmd
.try_get_matches_from(["publish"])
.expect("parsing `publish` with no args must succeed");
let parsed = Publish::from_arg_matches(&matches)
.expect("from_arg_matches must succeed for defaults");
assert_eq!(
parsed.prefix, DEFAULT_PUBLISH_TAG_PREFIX,
"Publish::prefix default must equal the lifted \
caixa_core::DEFAULT_PUBLISH_TAG_PREFIX — drift between this \
writer-side default and the peer caixa-flux deploy-side \
default silently breaks FluxCD GitRepository tag resolution"
);
}
#[test]
fn publish_remote_default_pins_lifted_caixa_core_constant() {
let cmd = Publish::augment_args(clap::Command::new("publish"));
let matches = cmd
.try_get_matches_from(["publish"])
.expect("parsing `publish` with no args must succeed");
let parsed = Publish::from_arg_matches(&matches)
.expect("from_arg_matches must succeed for defaults");
assert_eq!(
parsed.remote, DEFAULT_GIT_REMOTE,
"Publish::remote default must equal the lifted \
caixa_core::DEFAULT_GIT_REMOTE — drift between this \
writer-side default and the peer `feira deploy --apply` / \
`feira app deploy --apply` `push_origin` helpers silently \
emits a `git push` against the wrong remote on one verb \
while the others still target the canonical one"
);
}
fn fixture_caixa(nome: &str, versao: &str) -> Caixa {
let src = format!(
"(defcaixa :nome \"{nome}\" :versao \"{versao}\" \
:kind Biblioteca :bibliotecas ())"
);
Caixa::from_lisp(&src).expect("fixture caixa parses")
}
#[test]
fn publish_effective_versao_routes_through_caixa_versao_accessor() {
let caixa = fixture_caixa("checkout", "0.4.2");
assert_eq!(
publish_effective_versao(None, &caixa),
caixa.versao(),
"publish_effective_versao with no CLI override must \
byte-equal the typed Caixa::versao accessor — a regression \
that re-inlines the raw `caixa.versao.clone()` field-access \
at this site silently splits the origin-pushed git tag \
from the paired substrate-side renderer emit"
);
assert_eq!(publish_effective_versao(None, &caixa), "0.4.2");
}
#[test]
fn publish_effective_versao_carries_cli_override_verbatim() {
let caixa = fixture_caixa("cart", "1.0.0");
assert_eq!(
publish_effective_versao(Some("2.0.0-rc.1"), &caixa),
"2.0.0-rc.1",
"publish_effective_versao with --versao <v> must carry the \
CLI override verbatim, ignoring the manifest's :versao"
);
}
#[test]
fn publish_tag_message_routes_through_caixa_nome_accessor() {
let caixa = fixture_caixa("checkout", "0.4.2");
let tag = "v0.4.2";
let rendered = publish_tag_message(&caixa, tag);
assert_eq!(
rendered,
format!("caixa {} {tag}", caixa.nome()),
"publish_tag_message must derive its {{nome}} slot through \
the typed Caixa::nome accessor — a regression that re-\
inlines caixa.nome at the emit site silently splits the \
annotated-tag body from the paired substrate-side renderer \
emit"
);
assert_eq!(rendered, "caixa checkout v0.4.2");
}
#[test]
fn publish_tag_message_carries_arbitrary_tag_verbatim() {
let caixa = fixture_caixa("payment", "0.1.0");
assert_eq!(
publish_tag_message(&caixa, "release/2.0.0-rc.1"),
"caixa payment release/2.0.0-rc.1"
);
}
}