use std::io::Write as _;
use std::path::{Path, PathBuf};
use clap::Args;
use serde::Serialize;
use ossctl_core::contract::schema::{DistributionAdapter, Status};
use ossctl_core::contract::{self, LoadError, Normalized};
use ossctl_core::ports::CommandRunner;
use crate::cli::DistAction;
use crate::error::CliError;
use crate::output::OutputFormat;
use crate::sys::{RealCommandRunner, RealFs};
const DIST_CONFIG_FILENAME: &str = "dist-workspace.toml";
const RELEASE_WORKFLOW_PATH: &str = ".github/workflows/release.yml";
#[derive(Args, Debug)]
pub struct GenerateArgs {
#[arg(long, value_name = "PATH")]
pub repo_root: Option<PathBuf>,
#[arg(long)]
pub require_approved: bool,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub no_workflow: bool,
}
pub fn dispatch(action: DistAction, format: OutputFormat) -> Result<(), CliError> {
match action {
DistAction::Generate(args) => generate(&args, format, &RealCommandRunner),
}
}
#[derive(Debug, Serialize)]
struct DistReport {
dist_config: &'static str,
workflow: Option<&'static str>,
cargo_dist_version: &'static str,
targets: Vec<String>,
installers: Vec<String>,
}
pub fn generate(
args: &GenerateArgs,
format: OutputFormat,
runner: &dyn CommandRunner,
) -> Result<(), CliError> {
let root = resolve_and_canonicalize(args.repo_root.as_ref())?;
let normalized = contract::normalize(&root, &RealFs).map_err(load_error_to_cli)?;
if !normalized.is_valid() {
return Err(invalid_contract_error(&normalized));
}
if args.require_approved && normalized.contract.status != Status::Approved {
return Err(CliError::user(
"not_approved",
format!(
"{} is `{}`, not `approved` — a mutating orchestrator refuses to scaffold from a \
draft (drop --require-approved to generate anyway)",
contract::CONTRACT_FILENAME,
normalized.contract.status.as_str()
),
)
.with_invalid_value(normalized.contract.status.as_str().to_string()));
}
let distribution = normalized.contract.distribution.as_ref().ok_or_else(|| {
CliError::user(
"no_distribution",
format!(
"{} declares no `distribution` block — there is no binary-release infra to \
generate. Add a distribution block (adapter, platforms, installers) to the \
contract, or use a registry-only release",
contract::CONTRACT_FILENAME,
),
)
})?;
if distribution.adapter != DistributionAdapter::CargoDist {
return Err(CliError::user(
"unsupported_distribution_adapter",
format!(
"`dist generate` scaffolds the cargo-dist adapter, but the contract's \
distribution.adapter is `{}`. Only `cargo-dist` is supported; a \
goreleaser/manual scaffolder is a separate follow-up",
distribution.adapter.as_str()
),
)
.with_invalid_value(distribution.adapter.as_str())
.with_expected(serde_json::json!([DistributionAdapter::CargoDist.as_str()])));
}
let generated = ossctl_core::dist::generate(distribution);
let config_path = root.join(DIST_CONFIG_FILENAME);
write_config(&config_path, &generated.toml, args.force)?;
let mut warnings = generated.warnings.clone();
let workflow = if args.no_workflow {
warnings.push(format!(
"skipped the workflow step (--no-workflow); {DIST_CONFIG_FILENAME} was written but \
{RELEASE_WORKFLOW_PATH} was NOT regenerated — re-run `ossctl dist generate` (without \
--no-workflow) to produce it"
));
None
} else {
run_dist_generate(runner, &root)?;
Some(RELEASE_WORKFLOW_PATH)
};
let report = DistReport {
dist_config: DIST_CONFIG_FILENAME,
workflow,
cargo_dist_version: generated.cargo_dist_version,
targets: generated.targets.clone(),
installers: generated.installers.clone(),
};
match format {
OutputFormat::Json => crate::output::emit_json(&report, &warnings)?,
OutputFormat::Text => render_text(&report, &warnings),
}
Ok(())
}
fn write_config(path: &Path, contents: &str, force: bool) -> Result<(), CliError> {
let io_err = |e: std::io::Error| {
CliError::system("io_error", format!("cannot write {}: {e}", path.display()))
};
match std::fs::read(path) {
Ok(existing) if existing == contents.as_bytes() => Ok(()), Ok(_) if !force => Err(CliError::user(
"dist_config_exists",
format!(
"{DIST_CONFIG_FILENAME} already exists at {} with different content — pass --force \
to overwrite it (a hand-tuned config is not clobbered by default)",
path.display()
),
)
.with_invalid_value(path.display().to_string())),
Ok(_) => atomic_replace(path, contents).map_err(io_err),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map_err(io_err)?;
f.write_all(contents.as_bytes()).map_err(io_err)
}
Err(e) => Err(io_err(e)),
}
}
fn atomic_replace(path: &Path, contents: &str) -> std::io::Result<()> {
let tmp = path.with_extension("toml.tmp");
{
let mut f = std::fs::File::create(&tmp)?;
f.write_all(contents.as_bytes())?;
f.sync_all()?;
}
std::fs::rename(&tmp, path)
}
fn run_dist_generate(runner: &dyn CommandRunner, root: &Path) -> Result<(), CliError> {
let output = runner.run("dist", &["generate"], root).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
CliError::system(
"dist_tool_missing",
format!(
"wrote {DIST_CONFIG_FILENAME}, but the `dist` (cargo-dist) tool is not \
installed, so {RELEASE_WORKFLOW_PATH} was not generated. Install it \
(`cargo install cargo-dist --version {pinned} --locked`, or the curl \
installer from https://opensource.axo.dev/cargo-dist/) and re-run \
`ossctl dist generate`, or pass --no-workflow to skip this step",
pinned = ossctl_core::dist::PINNED_CARGO_DIST_VERSION,
),
)
} else {
CliError::system(
"dist_generate_failed",
format!("wrote {DIST_CONFIG_FILENAME}, but running `dist generate` failed: {e}"),
)
}
})?;
if output.status != Some(0) {
let detail = match (output.stderr.trim(), output.stdout.trim()) {
("", "") => "cargo-dist produced no diagnostic output".to_string(),
("", out) => out.to_string(),
(err, "") => err.to_string(),
(err, out) => format!("{err}\n{out}"),
};
return Err(CliError::system(
"dist_generate_failed",
format!(
"wrote {DIST_CONFIG_FILENAME}, but `dist generate` exited with status {} — \
{RELEASE_WORKFLOW_PATH} may be incomplete. Details: {detail}",
output
.status
.map_or_else(|| "signal".to_string(), |c| c.to_string()),
),
));
}
let workflow_path = root.join(RELEASE_WORKFLOW_PATH);
if !workflow_path.is_file() {
return Err(CliError::system(
"dist_workflow_missing",
format!(
"`dist generate` exited 0 but did not produce {} — the installed cargo-dist may \
differ from the pinned {} in {DIST_CONFIG_FILENAME}",
workflow_path.display(),
ossctl_core::dist::PINNED_CARGO_DIST_VERSION,
),
));
}
Ok(())
}
fn render_text(report: &DistReport, warnings: &[String]) {
println!("wrote: {}", report.dist_config);
match report.workflow {
Some(w) => println!("generated: {w}"),
None => println!("generated: (skipped — --no-workflow)"),
}
println!("cargo-dist-version: {}", report.cargo_dist_version);
println!("installers: {}", report.installers.join(", "));
println!("targets: {}", report.targets.len());
for t in &report.targets {
println!(" {t}");
}
for w in warnings {
println!("warning: {w}");
}
}
fn resolve_and_canonicalize(flag: Option<&PathBuf>) -> Result<PathBuf, CliError> {
let repo_root = match flag {
Some(p) => p.clone(),
None => std::env::current_dir()
.map_err(|e| CliError::system("io_error", format!("cannot resolve cwd: {e}")))?,
};
if !repo_root.is_dir() {
return Err(CliError::user(
"invalid_repo_root",
format!("repo_root '{}' is not a directory", repo_root.display()),
)
.with_invalid_value(repo_root.display().to_string()));
}
std::fs::canonicalize(&repo_root).map_err(|e| {
CliError::system(
"io_error",
format!(
"cannot canonicalize repo_root '{}': {e}",
repo_root.display()
),
)
})
}
fn load_error_to_cli(e: LoadError) -> CliError {
let code = match e {
LoadError::NotFound(_) => "contract_not_found",
LoadError::Io(..) => "io_error",
LoadError::Utf8(_) => "invalid_encoding",
};
CliError::system(code, e.to_string())
}
fn invalid_contract_error(normalized: &Normalized) -> CliError {
let problems = &normalized.problems.errors;
let message = format!(
"{} would not normalize: {} problem(s) — fix the contract before generating dist config",
contract::CONTRACT_FILENAME,
problems.len()
);
CliError::user("invalid_contract", message).with_problems(problems.clone())
}
#[cfg(test)]
#[path = "dist_tests.rs"]
mod tests;