use anyhow::{bail, Context, Result};
use clap::{Args, Parser, Subcommand};
use intentional_core::{
check_workspace, discover_config, ApplyResult, Bump, Config, IntentDraft, ReleasePlan,
StampResult, TagResult, WorkspaceStatus, CONFIG_PATH,
};
use std::collections::BTreeMap;
use std::io::{self, Write};
use std::path::PathBuf;
#[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(DryRun),
Add(AddArgs),
Status,
Plan(ChannelArgs),
Apply(ChannelDryRunArgs),
Stamp(StampArgs),
Tag(ChannelDryRunArgs),
Check,
}
#[derive(Debug, Args)]
struct DryRun {
#[arg(long)]
dry_run: bool,
}
#[derive(Debug, Args)]
struct AddArgs {
#[arg(long = "package")]
packages: 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 StampArgs {
#[arg(long)]
prerelease: Option<String>,
#[arg(long)]
dry_run: bool,
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Init(args) => init(&cli.directory, args.dry_run),
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.channel.as_deref(), args.dry_run),
Command::Check => check(&cli.directory),
}
}
fn check(root: &std::path::Path) -> Result<()> {
check_workspace(root)?;
println!("check passed");
Ok(())
}
fn tag(root: &std::path::Path, channel: Option<&str>, dry_run: bool) -> Result<()> {
let result = TagResult::build(root, channel)?;
for operation in result.operations() {
println!("{operation}");
}
result.apply(root, 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, dry_run: bool) -> Result<()> {
let result = discover_config(root)?;
println!("write {}", result.path.display());
println!("create .intentional/intents/");
result.apply(root, dry_run)?;
Ok(())
}
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 package_values = if args.packages.is_empty() {
let ids = config.packages.keys().cloned().collect::<Vec<_>>();
println!("Packages: {}", ids.join(", "));
vec![prompt("Package (id): ")? + ":" + &prompt("Bump (major|minor|patch): ")?]
} else {
args.packages
};
let mut packages = BTreeMap::new();
for value in package_values {
let (id, bump) = value
.split_once(':')
.with_context(|| format!("package bump must be id:bump; got {value}"))?;
let bump = bump.parse::<Bump>().map_err(anyhow::Error::msg)?;
if packages.insert(id.to_owned(), bump).is_some() {
bail!("package {id} was specified more than once");
}
}
let message = match args.message {
Some(message) => message,
None => prompt("Changelog message: ")?,
};
let write = IntentDraft { packages, 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(", "));
}
for package in status.packages {
println!(
"{}: {} -> {} ({})",
package.id, package.current, package.next, package.bump
);
}
if status.drift.is_empty() {
println!("Drift: none");
} else {
println!("Drift:");
for drift in status.drift {
println!(
" {} {}: manifest {} != tag {}",
drift.package,
drift.file.display(),
drift.actual,
drift.expected
);
}
}
Ok(())
}
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())
}