use anyhow::{bail, Context, Result};
use clap::{Args, Parser, Subcommand};
use intentional_core::{
check_workspace, initialize, ApplyResult, Bump, Config, InitState, IntentDraft, ReleasePlan,
StampResult, TagPhase, TagResult, WorkspaceStatus, CONFIG_PATH, MISSING_BASELINE_CODE,
MISSING_BASELINE_NEXT_ACTION,
};
use semver::Version;
use std::collections::BTreeMap;
use std::io::{self, Write};
use std::path::PathBuf;
const SKILL_DOCUMENT: &str = include_str!("../skills/intentional/SKILL.md");
#[derive(Debug, Parser)]
#[command(
name = "intentional",
version,
about = "Intent-driven polyglot releases"
)]
struct Cli {
#[arg(short = 'C', long, default_value = ".", global = true)]
directory: PathBuf,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Init(InitArgs),
Add(AddArgs),
Status,
Plan(ChannelArgs),
Apply(ChannelDryRunArgs),
Stamp(StampArgs),
Tag(TagArgs),
Check,
Skill,
}
#[derive(Debug, Args)]
struct DryRun {
#[arg(long)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct InitArgs {
#[arg(long)]
take_over: bool,
#[arg(long)]
json: bool,
#[arg(long)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct AddArgs {
#[arg(long = "release-unit")]
release_units: Vec<String>,
#[arg(long)]
message: Option<String>,
#[arg(long)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct ChannelArgs {
#[arg(long)]
channel: Option<String>,
}
#[derive(Debug, Args)]
struct ChannelDryRunArgs {
#[arg(long)]
channel: Option<String>,
#[arg(long)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct TagArgs {
#[arg(long)]
channel: Option<String>,
#[arg(long)]
baseline: bool,
#[arg(long = "version")]
versions: Vec<String>,
#[arg(long)]
phase: Option<String>,
#[arg(long, value_name = "PATH")]
plan: Option<PathBuf>,
#[arg(long)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct StampArgs {
#[arg(long)]
prerelease: Option<String>,
#[arg(long)]
dry_run: bool,
}
fn main() -> std::process::ExitCode {
match run() {
Ok(code) => std::process::ExitCode::from(code),
Err(error) => {
eprintln!("{error:#}");
std::process::ExitCode::FAILURE
}
}
}
fn run() -> Result<u8> {
let cli = Cli::parse();
match cli.command {
Command::Init(args) => return init(&cli.directory, args),
Command::Add(args) => add(&cli.directory, args),
Command::Status => status(&cli.directory),
Command::Plan(args) => plan(&cli.directory, args.channel.as_deref()),
Command::Apply(args) => apply(&cli.directory, args.channel.as_deref(), args.dry_run),
Command::Stamp(args) => stamp(&cli.directory, args.prerelease.as_deref(), args.dry_run),
Command::Tag(args) => tag(&cli.directory, args),
Command::Check => check(&cli.directory),
Command::Skill => skill(),
}?;
Ok(0)
}
fn skill() -> Result<()> {
print!("{SKILL_DOCUMENT}");
Ok(())
}
fn check(root: &std::path::Path) -> Result<()> {
check_workspace(root)?;
println!("check passed");
Ok(())
}
fn tag(root: &std::path::Path, args: TagArgs) -> Result<()> {
let phase = args.phase.as_deref().map(parse_phase).transpose()?;
let mut explicit = BTreeMap::new();
for value in args.versions {
let (id, version) = value
.split_once('=')
.with_context(|| format!("baseline version must be id=X.Y.Z; got {value}"))?;
let version = Version::parse(version)?;
if explicit.insert(id.to_owned(), version).is_some() {
bail!("baseline version {id} was specified more than once");
}
}
if args.baseline && args.channel.is_some() {
bail!("--baseline and --channel cannot be combined");
}
if args.baseline && args.plan.is_some() {
bail!("--baseline and --plan cannot be combined");
}
if !args.baseline && !explicit.is_empty() {
bail!("--version is valid only with --baseline");
}
let result = if args.baseline {
TagResult::build_baseline(root, &explicit)?
} else {
let plan_path = args.plan.as_ref().map(|path| {
if path.is_absolute() {
path.clone()
} else {
root.join(path)
}
});
TagResult::build_with_plan(root, args.channel.as_deref(), phase, plan_path.as_deref())?
};
for operation in result.operations() {
println!("{operation}");
}
result.apply(root, args.dry_run)?;
Ok(())
}
fn stamp(root: &std::path::Path, prerelease: Option<&str>, dry_run: bool) -> Result<()> {
let result = StampResult::build(root, prerelease)?;
for operation in result.operations() {
println!("{operation}");
}
result.apply(root, dry_run)?;
Ok(())
}
fn apply(root: &std::path::Path, channel: Option<&str>, dry_run: bool) -> Result<()> {
let result = ApplyResult::build(root, channel)?;
for operation in result.operations() {
println!("{operation}");
}
result.apply(root, dry_run)?;
Ok(())
}
fn plan(root: &std::path::Path, channel: Option<&str>) -> Result<()> {
let plan = ReleasePlan::build(root, channel)?;
println!("{}", plan.to_canonical_json()?);
Ok(())
}
fn init(root: &std::path::Path, args: InitArgs) -> Result<u8> {
let result = initialize(root, args.take_over)?;
if args.json {
println!("{}", result.to_json()?);
} else {
println!("initialization state: {:?}", result.state);
for operation in &result.operations {
println!("{operation}");
}
if result.plan.is_some() && !args.take_over {
println!("plan: {}", result.path.display());
}
}
result.apply(root, args.dry_run)?;
Ok(if result.state == InitState::NeedsInput {
2
} else {
0
})
}
fn add(root: &std::path::Path, args: AddArgs) -> Result<()> {
let config = Config::load(root)
.with_context(|| format!("load {CONFIG_PATH} before adding an intent"))?;
let release_unit_values = if args.release_units.is_empty() {
let ids = config.release_units.keys().cloned().collect::<Vec<_>>();
println!("Release units: {}", ids.join(", "));
vec![prompt("Release unit (id): ")? + ":" + &prompt("Bump (major|minor|patch): ")?]
} else {
args.release_units
};
let mut release_units = BTreeMap::new();
for value in release_unit_values {
let (id, bump) = value
.split_once(':')
.with_context(|| format!("release-unit bump must be id:bump; got {value}"))?;
let bump = bump.parse::<Bump>().map_err(anyhow::Error::msg)?;
if release_units.insert(id.to_owned(), bump).is_some() {
bail!("release unit {id} was specified more than once");
}
}
let message = match args.message {
Some(message) => message,
None => prompt("Changelog message: ")?,
};
let write = IntentDraft {
release_units,
message,
}
.plan(root, &config)?;
println!("write {}", write.path.display());
write.apply(root, args.dry_run)?;
Ok(())
}
fn status(root: &std::path::Path) -> Result<()> {
let status = WorkspaceStatus::load(root)?;
if status.intents.is_empty() {
println!("Pending intents: none");
} else {
println!("Pending intents: {}", status.intents.join(", "));
}
if !status.missing_baselines.is_empty() {
println!(
"[{MISSING_BASELINE_CODE}] Missing baseline tags: {}; run {MISSING_BASELINE_NEXT_ACTION}",
status.missing_baselines.join(", "),
);
}
for issue in &status.tag_record_issues {
println!("Tag record: {issue}");
}
for release_unit in status.release_units {
println!(
"{}: {} -> {} ({})",
release_unit.id, release_unit.current, release_unit.next, release_unit.bump
);
}
if status.drift.is_empty() {
println!("Drift: none");
} else {
println!("Drift:");
for drift in status.drift {
println!(
" {} {}: manifest {} != tag {}",
drift.release_unit,
drift.file.display(),
drift.actual,
drift.expected
);
}
}
Ok(())
}
fn parse_phase(value: &str) -> Result<TagPhase> {
match value {
"before-publication" => Ok(TagPhase::BeforePublication),
"after-publication" => Ok(TagPhase::AfterPublication),
_ => bail!("phase must be before-publication or after-publication; got {value}"),
}
}
fn prompt(label: &str) -> Result<String> {
print!("{label}");
io::stdout().flush()?;
let mut value = String::new();
io::stdin().read_line(&mut value)?;
Ok(value.trim().to_owned())
}