Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/applier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,14 @@ struct ApplyOutput {
next: Vec<NextStep>,
}

/// Execute `rep apply`.
pub fn run(plan_id: String, json: bool) -> Result<i32> {
/// Execute `rep apply`. `plan_id: None` means `--last`: the most recent plan,
/// resolved from the state pointer.
pub fn run(plan_id: Option<String>, json: bool) -> Result<i32> {
let root = git::discover_root()?;
let plan_id = match plan_id {
Some(id) => id,
None => artifacts::last_plan_id(&root)?,
};
let mut plan = artifacts::read_plan(&root, &plan_id)?;

// --- validation (all before any write) ---
Expand Down
11 changes: 11 additions & 0 deletions src/artifacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,17 @@ pub fn update_plan(root: &Path, plan: &Plan) -> Result<()> {
write_json(&path, plan)
}

/// Resolve the most recent plan id from the active-state pointer — the plan
/// `rep status` reports. Backs `--last` so callers need not copy plan ids.
pub fn last_plan_id(root: &Path) -> Result<String> {
match read_state(root)? {
Some(state) => Ok(state.active_plan_id),
None => Err(RepError::InvalidArguments(
"no plan found for --last; run `rep plan --map FROM=TO` first".to_string(),
)),
}
}

/// Read the active-state pointer, if present.
pub fn read_state(root: &Path) -> Result<Option<State>> {
let path = rep_dir(root).join("state.json");
Expand Down
33 changes: 24 additions & 9 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,32 +133,47 @@ You describe the rename as one or more literal mappings with --map FROM=TO \
#[command(long_about = "\
Apply a previously previewed plan to the working tree.

<PLAN> is the <plan-id> printed by 'rep plan'. apply refuses to run if tracked \
files changed since the plan was built, so the preview always matches what gets \
written.")]
#[command(after_help = "Example:\n rep apply --plan <plan-id>")]
<PLAN> is the <plan-id> printed by 'rep plan'; '--last' applies the most \
recent plan (the one 'rep status' shows) without copying the id. apply \
refuses to run if tracked files changed since the plan was built, so the \
preview always matches what gets written.")]
#[command(
after_help = "Example:\n rep apply --plan <plan-id>\n rep apply --last # the plan `rep status` shows"
)]
#[command(group = clap::ArgGroup::new("plan_ref").required(true).args(["plan", "last"]))]
Apply {
/// The <plan-id> printed by `rep plan`
#[arg(long)]
plan: String,
plan: Option<String>,

/// Apply the most recent plan (the one `rep status` shows)
#[arg(long)]
last: bool,
},

/// Confirm an old token is gone (leftover check after applying)
#[command(long_about = "\
Confirm an old token is gone -- a leftover check you run after applying.

'residual' is any remaining occurrence of the old token in tracked content or \
paths. Pass the token directly, or use --plan to derive the tokens from a \
plan's FROM sides. Exits non-zero (code 8) if anything is left.")]
#[command(after_help = "Example:\n rep residual old_name\n rep residual --plan <plan-id>")]
paths. Pass the token directly, or use --plan (or --last for the most recent \
plan) to derive the tokens from a plan's FROM sides. Exits non-zero (code 8) \
if anything is left.")]
#[command(
after_help = "Example:\n rep residual old_name\n rep residual --plan <plan-id>\n rep residual --last"
)]
Residual {
/// The token to check is gone (omit when using --plan)
/// The token to check is gone (omit when using --plan/--last)
token: Option<String>,

/// Derive tokens to check from a plan's mapping FROM sides
#[arg(long)]
plan: Option<String>,

/// Derive tokens from the most recent plan (the one `rep status` shows)
#[arg(long, conflicts_with_all = ["plan", "token"])]
last: bool,

/// Match case-insensitively (ASCII)
#[arg(long)]
case_insensitive: bool,
Expand Down
8 changes: 6 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ fn suggested_command() -> &'static str {
match sub.as_deref() {
Some("scan") => "rep scan old_name",
Some("plan") => "rep plan --map old_name=new_name",
Some("apply") => "rep apply --plan <plan-id>",
Some("apply") => "rep apply --last",
Some("residual") => "rep residual old_name",
Some("status") => "rep status",
_ => "rep scan old_name (list every command with 'rep --help')",
Expand Down Expand Up @@ -170,11 +170,14 @@ fn dispatch(cli: Cli) -> Result<i32> {
)
}

Commands::Apply { plan } => applier::run(plan, json),
// The clap ArgGroup guarantees exactly one of --plan/--last, so
// `plan: None` here always means `--last`.
Commands::Apply { plan, last: _ } => applier::run(plan, json),

Commands::Residual {
token,
plan,
last,
case_insensitive,
include,
exclude,
Expand All @@ -183,6 +186,7 @@ fn dispatch(cli: Cli) -> Result<i32> {
ResidualOpts {
token,
plan,
last,
case_insensitive,
scope: ScopeOpts {
include,
Expand Down
16 changes: 12 additions & 4 deletions src/residual.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ struct ResidualReport {
pub struct ResidualOpts {
pub token: Option<String>,
pub plan: Option<String>,
/// Derive tokens from the most recent plan (state pointer) instead of an
/// explicit `--plan` id or positional token.
pub last: bool,
pub case_insensitive: bool,
pub scope: ScopeOpts,
}
Expand Down Expand Up @@ -113,17 +116,22 @@ pub fn run(opts: ResidualOpts, json: bool) -> Result<i32> {
Ok(if passed { 0 } else { 8 })
}

/// Resolve the tokens to check: each mapping `from` for `--plan`, otherwise the
/// single positional token.
/// Resolve the tokens to check: each mapping `from` for `--plan`/`--last`,
/// otherwise the single positional token.
fn resolve_tokens(root: &std::path::Path, opts: &ResidualOpts) -> Result<Vec<String>> {
if let Some(plan_id) = &opts.plan {
let plan_id = if opts.last {
Some(artifacts::last_plan_id(root)?)
} else {
opts.plan.clone()
};
if let Some(plan_id) = &plan_id {
let plan = artifacts::read_plan(root, plan_id)?;
Ok(plan.mappings.into_iter().map(|m| m.from).collect())
} else if let Some(token) = &opts.token {
Ok(vec![token.clone()])
} else {
Err(RepError::InvalidArguments(
"either a TOKEN or --plan PLAN_ID is required".to_string(),
"either a TOKEN, --plan PLAN_ID, or --last is required".to_string(),
))
}
}
Expand Down
54 changes: 54 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,60 @@ fn map_file_stdin_twice_exit_10() {
assert_eq!(res.code, 10);
}

// apply --last applies the most recent plan (the one the state pointer names)
#[test]
fn apply_last_applies_latest_plan() {
let dir = setup_success_example();
let _first = plan_three_maps(dir.path(), &[]);
let second = plan_three_maps(dir.path(), &[]);
let res = rep(dir.path(), &["apply", "--last", "--json"]);
assert_eq!(res.code, 0, "apply failed: {}", res.stdout);
assert_eq!(res.json()["plan_id"].as_str().unwrap(), second);
}

// --plan and --last are mutually exclusive
#[test]
fn apply_plan_and_last_conflict_exit_10() {
let dir = setup_success_example();
let plan_id = plan_three_maps(dir.path(), &[]);
let res = rep(
dir.path(),
&["apply", "--plan", &plan_id, "--last", "--json"],
);
assert_eq!(res.code, 10);
}

// apply --last with no plans yet is a usage error pointing at rep plan
#[test]
fn apply_last_without_plans_exit_10() {
let dir = setup_success_example();
let res = rep(dir.path(), &["apply", "--last", "--json"]);
assert_eq!(res.code, 10);
let msg = res.json()["error"]["message"].as_str().unwrap().to_string();
assert!(msg.contains("rep plan"), "message: {msg}");
}

// residual --last derives tokens from the most recent plan
#[test]
fn residual_last_checks_latest_plan_tokens() {
let dir = setup_success_example();
plan_three_maps(dir.path(), &["--rename-paths"]);
let res = rep(dir.path(), &["apply", "--last", "--json"]);
assert_eq!(res.code, 0, "apply failed: {}", res.stdout);
let res = rep(dir.path(), &["residual", "--last", "--json"]);
assert_eq!(res.code, 0, "residual failed: {}", res.stdout);
assert_eq!(res.json()["tokens"].as_array().unwrap().len(), 3);
}

// a positional token conflicts with --last
#[test]
fn residual_token_with_last_exit_10() {
let dir = setup_success_example();
plan_three_maps(dir.path(), &[]);
let res = rep(dir.path(), &["residual", "oldname", "--last", "--json"]);
assert_eq!(res.code, 10);
}

// matched_directories reports the token-bearing directory prefix
#[test]
fn matched_directory_prefix() {
Expand Down
Loading