diff --git a/src/artifacts.rs b/src/artifacts.rs index 9f19838..078b4b7 100644 --- a/src/artifacts.rs +++ b/src/artifacts.rs @@ -138,6 +138,26 @@ pub fn update_plan(root: &Path, plan: &Plan) -> Result<()> { write_json(&path, plan) } +/// A suggested follow-up command for a plan in a given state. +#[derive(Clone, Debug, Serialize)] +pub struct NextStep { + pub command: String, +} + +/// The follow-up commands for a plan in `state`; shared by `rep status` and +/// `rep show` so both always suggest the same next action. +pub fn next_steps(state: &str, plan_id: &str) -> Vec { + match state { + STATE_PLANNED => vec![NextStep { + command: format!("rep apply --plan {plan_id} --json"), + }], + STATE_APPLIED => vec![NextStep { + command: format!("rep residual --plan {plan_id} --json"), + }], + _ => vec![], + } +} + /// 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 { diff --git a/src/cli.rs b/src/cli.rs index 30b5157..24cbb5b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -191,6 +191,39 @@ if anything is left.")] tracked_only: bool, }, + /// Inspect a saved plan (summary; add --files/--skipped/--preview detail) + #[command(long_about = "\ +Inspect a saved plan without reading .rep/plans// files by hand. + +Prints the plan's mappings, counts, state, and suggested next command. \ +--files, --skipped, and --preview add the corresponding detail sections. \ +With no --plan, shows the most recent plan (the one 'rep status' shows).")] + #[command( + after_help = "Example:\n rep show # most recent plan\n rep show --plan --skipped" + )] + Show { + /// The to inspect (defaults to the most recent plan) + #[arg(long, conflicts_with = "last")] + plan: Option, + + /// Inspect the most recent plan (the default; accepted for symmetry + /// with `rep apply --last`) + #[arg(long)] + last: bool, + + /// Also list planned content files and path renames + #[arg(long)] + files: bool, + + /// Also list skipped paths with reasons + #[arg(long)] + skipped: bool, + + /// Also print the line-level content preview + #[arg(long)] + preview: bool, + }, + /// Show rep's current state for this repository (last plan, etc.) Status, } diff --git a/src/lib.rs b/src/lib.rs index 3af108b..3c7b893 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,7 @@ pub mod residual; pub mod scanner; pub mod schema; pub mod scope; +pub mod show; pub mod status; pub mod text; diff --git a/src/main.rs b/src/main.rs index e66b943..8c5f880 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,8 +10,9 @@ use rep::error::{RepError, Result}; use rep::planner::PlanOpts; use rep::residual::ResidualOpts; use rep::scope::ScopeOpts; +use rep::show::ShowOpts; use rep::text; -use rep::{applier, output, planner, residual, scanner, status}; +use rep::{applier, output, planner, residual, scanner, show, status}; fn main() -> ExitCode { let cli = match Cli::try_parse() { @@ -87,6 +88,7 @@ fn suggested_command() -> &'static str { Some("plan") => "rep plan --map old_name=new_name", Some("apply") => "rep apply --last", Some("residual") => "rep residual old_name", + Some("show") => "rep show", Some("status") => "rep status", _ => "rep scan old_name (list every command with 'rep --help')", } @@ -197,6 +199,24 @@ fn dispatch(cli: Cli) -> Result { json, ), + // `last` only exists for CLI symmetry; `plan: None` already means the + // most recent plan. + Commands::Show { + plan, + last: _, + files, + skipped, + preview, + } => show::run( + ShowOpts { + plan, + files, + skipped, + preview, + }, + json, + ), + Commands::Status => status::run(json), } } diff --git a/src/schema.rs b/src/schema.rs index 39d9038..58c4f24 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -12,6 +12,8 @@ pub const APPLY: &str = "rep.apply.v1"; pub const RESIDUAL: &str = "rep.residual.v1"; /// `rep status` output schema version. pub const STATUS: &str = "rep.status.v1"; +/// `rep show` output schema version. +pub const SHOW: &str = "rep.show.v1"; /// Machine-readable error output schema version (emitted on `--json` failures). pub const ERROR: &str = "rep.error.v1"; diff --git a/src/show.rs b/src/show.rs new file mode 100644 index 0000000..2715306 --- /dev/null +++ b/src/show.rs @@ -0,0 +1,167 @@ +//! `rep show` — inspect a saved plan without reading `.rep/plans//` +//! files by hand. +//! +//! The summary (mappings, counts, state, next step) always prints; `--files`, +//! `--skipped`, and `--preview` add the corresponding detail sections. + +use serde::Serialize; + +use crate::artifacts::{self, Artifacts, ContentFile, NextStep, RepoInfo}; +use crate::error::Result; +use crate::output; +use crate::path_rename::Rename; +use crate::scope::{Scope, Skip}; +use crate::text::Mapping; +use crate::{git, schema}; + +#[derive(Serialize)] +struct ContentSummary { + enabled: bool, + matched_files: usize, + changed_files: usize, + replacements: usize, +} + +#[derive(Serialize)] +struct PathsSummary { + enabled: bool, + matched_paths: usize, + renames: usize, +} + +#[derive(Serialize)] +struct FilesSection { + content: Vec, + renames: Vec, +} + +#[derive(Serialize)] +struct ShowOutput { + schema_version: String, + plan_id: String, + created_at: String, + state: String, + repo: RepoInfo, + scope: Scope, + mappings: Vec, + content: ContentSummary, + paths: PathsSummary, + skipped_count: usize, + artifacts: Artifacts, + next: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + files: Option, + #[serde(skip_serializing_if = "Option::is_none")] + skipped: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + preview: Option, +} + +/// Options for `rep show`. `plan: None` means the most recent plan (the one +/// the state pointer names), which is also what bare `rep show` does. +pub struct ShowOpts { + pub plan: Option, + pub files: bool, + pub skipped: bool, + pub preview: bool, +} + +/// Execute `rep show`. +pub fn run(opts: ShowOpts, json: bool) -> Result { + let root = git::discover_root()?; + let plan_id = match opts.plan { + Some(id) => id, + None => artifacts::last_plan_id(&root)?, + }; + let plan = artifacts::read_plan(&root, &plan_id)?; + + let preview = if opts.preview { + Some(std::fs::read_to_string( + root.join(&plan.artifacts.content_preview), + )?) + } else { + None + }; + + let out = ShowOutput { + schema_version: schema::SHOW.to_string(), + plan_id: plan.plan_id.clone(), + created_at: plan.created_at, + state: plan.state.clone(), + repo: plan.repo, + scope: plan.scope, + mappings: plan.mappings, + content: ContentSummary { + enabled: plan.content.enabled, + matched_files: plan.content.matched_files, + changed_files: plan.content.changed_files, + replacements: plan.content.replacements, + }, + paths: PathsSummary { + enabled: plan.paths.enabled, + matched_paths: plan.paths.matched_paths, + renames: plan.paths.renames.len(), + }, + skipped_count: plan.skipped.len(), + artifacts: plan.artifacts, + next: artifacts::next_steps(&plan.state, &plan.plan_id), + files: opts.files.then_some(FilesSection { + content: plan.content.files, + renames: plan.paths.renames, + }), + skipped: opts.skipped.then_some(plan.skipped), + preview, + }; + + if json { + output::print_json(&out)?; + } else { + print_human(&out); + } + + Ok(0) +} + +fn print_human(out: &ShowOutput) { + output::info(&format!( + "plan {} ({})", + output::bold(&out.plan_id), + out.state + )); + let short_head: String = out.repo.git_head.chars().take(12).collect(); + println!(" created: {} head: {}", out.created_at, short_head); + println!(" mappings:"); + for m in &out.mappings { + println!(" {} -> {}", m.from, m.to); + } + println!( + " content: {} replacements in {} files", + out.content.replacements, out.content.changed_files + ); + println!(" paths: {} renames", out.paths.renames); + println!(" skipped: {}", out.skipped_count); + if let Some(files) = &out.files { + println!(" files:"); + for f in &files.content { + println!(" {}", f.path); + } + for r in &files.renames { + println!(" {} -> {}", r.from, r.to); + } + } + if let Some(skipped) = &out.skipped { + println!(" skipped paths:"); + for s in skipped { + match &s.matched_rule { + Some(rule) => println!(" {} ({}: {})", s.path, s.reason, rule), + None => println!(" {} ({})", s.path, s.reason), + } + } + } + if let Some(preview) = &out.preview { + print!("{preview}"); + } + for step in &out.next { + output::action(&step.command); + } +} diff --git a/src/status.rs b/src/status.rs index 1af170d..69e22eb 100644 --- a/src/status.rs +++ b/src/status.rs @@ -2,7 +2,7 @@ use serde::Serialize; -use crate::artifacts::{self, STATE_NONE}; +use crate::artifacts::{self, NextStep, STATE_NONE, next_steps}; use crate::error::Result; use crate::output; use crate::text::Mapping; @@ -20,11 +20,6 @@ struct RepoInfo { tracked_tree_clean: bool, } -#[derive(Serialize)] -struct NextStep { - command: String, -} - #[derive(Serialize)] struct StatusReport { schema_version: String, @@ -80,18 +75,6 @@ pub fn run(json: bool) -> Result { Ok(0) } -fn next_steps(state: &str, plan_id: &str) -> Vec { - match state { - artifacts::STATE_PLANNED => vec![NextStep { - command: format!("rep apply --plan {plan_id} --json"), - }], - artifacts::STATE_APPLIED => vec![NextStep { - command: format!("rep residual --plan {plan_id} --json"), - }], - _ => vec![], - } -} - fn print_human(report: &StatusReport) { output::info(&format!("state: {}", output::bold(&report.state))); if let Some(id) = &report.active_plan_id { diff --git a/tests/cli.rs b/tests/cli.rs index 10ae565..138493a 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -713,6 +713,111 @@ fn residual_token_with_last_exit_10() { assert_eq!(res.code, 10); } +// bare rep show reports the most recent plan; detail sections stay absent +#[test] +fn show_defaults_to_last_plan() { + let dir = setup_success_example(); + let plan_id = plan_three_maps(dir.path(), &[]); + let res = rep(dir.path(), &["show", "--json"]); + assert_eq!(res.code, 0, "show failed: {}", res.stdout); + let json = res.json(); + assert_eq!(json["schema_version"], "rep.show.v1"); + assert_eq!(json["plan_id"].as_str().unwrap(), plan_id); + assert_eq!(json["state"], "planned"); + assert!(json["content"]["replacements"].as_u64().unwrap() > 0); + assert!(json.get("files").is_none()); + assert!(json.get("skipped").is_none()); + assert!(json.get("preview").is_none()); +} + +// --files/--skipped/--preview add their sections to the summary +#[test] +fn show_sections_add_detail() { + let dir = setup_success_example(); + let plan_id = plan_three_maps(dir.path(), &["--rename-paths", "--exclude", "README.md"]); + let res = rep( + dir.path(), + &[ + "show", + "--plan", + &plan_id, + "--files", + "--skipped", + "--preview", + "--json", + ], + ); + assert_eq!(res.code, 0, "show failed: {}", res.stdout); + let json = res.json(); + let content_files = json["files"]["content"].as_array().unwrap(); + assert!(content_files.iter().any(|f| f["path"] == "src/oldname.ts")); + let renames = json["files"]["renames"].as_array().unwrap(); + assert!(renames.iter().any(|r| r["from"] == "src/oldname.ts")); + let skipped = json["skipped"].as_array().unwrap(); + assert!( + skipped + .iter() + .any(|s| s["path"] == "README.md" && s["reason"] == "excluded_by_glob") + ); + assert!(json["preview"].as_str().unwrap().contains("src/oldname.ts")); +} + +// show reflects the applied state and suggests residual next +#[test] +fn show_after_apply_suggests_residual() { + let dir = setup_success_example(); + plan_three_maps(dir.path(), &[]); + let res = rep(dir.path(), &["apply", "--last", "--json"]); + assert_eq!(res.code, 0, "apply failed: {}", res.stdout); + let res = rep(dir.path(), &["show", "--last", "--json"]); + assert_eq!(res.code, 0, "show failed: {}", res.stdout); + let json = res.json(); + assert_eq!(json["state"], "applied"); + let next = json["next"][0]["command"].as_str().unwrap(); + assert!(next.contains("rep residual"), "next: {next}"); +} + +// an unknown plan id is a usage error +#[test] +fn show_unknown_plan_exit_10() { + let dir = setup_success_example(); + plan_three_maps(dir.path(), &[]); + let res = rep(dir.path(), &["show", "--plan", "bogus", "--json"]); + assert_eq!(res.code, 10); +} + +// --plan and --last are mutually exclusive +#[test] +fn show_plan_and_last_conflict_exit_10() { + let dir = setup_success_example(); + let plan_id = plan_three_maps(dir.path(), &[]); + let res = rep( + dir.path(), + &["show", "--plan", &plan_id, "--last", "--json"], + ); + assert_eq!(res.code, 10); +} + +// show with no plans yet points at rep plan +#[test] +fn show_without_plans_exit_10() { + let dir = setup_success_example(); + let res = rep(dir.path(), &["show", "--json"]); + assert_eq!(res.code, 10); + let msg = res.json()["error"]["message"].as_str().unwrap().to_string(); + assert!(msg.contains("rep plan"), "message: {msg}"); +} + +// human-mode show succeeds +#[test] +fn show_human_smoke() { + let dir = setup_success_example(); + plan_three_maps(dir.path(), &[]); + let res = rep(dir.path(), &["show"]); + assert_eq!(res.code, 0, "show failed: {}", res.stdout); + assert!(res.stdout.contains("oldname -> newname"), "{}", res.stdout); +} + // matched_directories reports the token-bearing directory prefix #[test] fn matched_directory_prefix() {