feat: MVP — move files, rewrite imports, atomically
- core: gitignore-aware index (ignore crate), forward/reverse import graph,
relative-specifier math, pure-data move plans
- apply: rewrite-first + rename-last with fsync, temp+rename, full rollback
(incl. stale-plan detection before any write); unified diff for dry-run
- cli: mv + check commands, human output, --json envelope with stable error
codes (SOURCE_NOT_FOUND, TARGET_EXISTS, ...) and hints; exit codes 0/1/2
- parser/core split into submodules to keep files <=250 lines
- tests: 37 unit + 10 e2e + 2 doctests; fixtures tests/typescript/{basic,normal,complex}
- docs: SKILL.md synced (no --force in Phase 1), EXAMPLES.md
- plan: AI/agent support done; Phase 1.5 = Java niche
This commit is contained in:
parent
9d8f1e0bba
commit
55670030c3
37 changed files with 1851 additions and 139 deletions
188
src/cli/json.rs
188
src/cli/json.rs
|
|
@ -2,10 +2,16 @@
|
|||
//!
|
||||
//! Every response is an [`Envelope`] whose `status` is one of
|
||||
//! `"ok" | "dry_run" | "error"`, plus a machine-readable error `code`
|
||||
//! and a human `hint` on failure (see `docs/SKILL.md`).
|
||||
//! and a human `hint` on failure (see `docs/SKILL.md`). Payload structs
|
||||
//! are flattened into the envelope, so they never repeat `operation`.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::core::JmoveError;
|
||||
use crate::core::plan::MovePlan;
|
||||
|
||||
use super::output;
|
||||
|
||||
/// Top-level envelope for every `--json` response.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Envelope<T: Serialize> {
|
||||
|
|
@ -18,6 +24,40 @@ pub struct Envelope<T: Serialize> {
|
|||
pub data: T,
|
||||
}
|
||||
|
||||
impl<T: Serialize> Envelope<T> {
|
||||
/// Success envelope: `status = "ok"`.
|
||||
#[must_use]
|
||||
pub fn ok(operation: &'static str, data: T) -> Self {
|
||||
Self {
|
||||
status: "ok",
|
||||
operation,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
/// Dry-run preview envelope: `status = "dry_run"`.
|
||||
#[must_use]
|
||||
pub fn dry_run(operation: &'static str, data: T) -> Self {
|
||||
Self {
|
||||
status: "dry_run",
|
||||
operation,
|
||||
data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Envelope<ErrorData> {
|
||||
/// Failure envelope: `status = "error"` with a flattened [`ErrorData`].
|
||||
#[must_use]
|
||||
pub fn error(operation: &'static str, data: ErrorData) -> Self {
|
||||
Self {
|
||||
status: "error",
|
||||
operation,
|
||||
data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error payload: stable `code`, human `message`, actionable `hint`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ErrorData {
|
||||
|
|
@ -25,12 +65,152 @@ pub struct ErrorData {
|
|||
pub code: String,
|
||||
/// Human-readable explanation.
|
||||
pub message: String,
|
||||
/// What the caller should do next (never null in output; omit if none).
|
||||
/// What the caller should do next (omitted from JSON when absent).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hint: Option<String>,
|
||||
}
|
||||
|
||||
impl ErrorData {
|
||||
/// Build an error payload from a stable code, message and optional hint.
|
||||
#[must_use]
|
||||
pub fn new(code: &str, message: String, hint: Option<String>) -> Self {
|
||||
Self {
|
||||
code: code.to_owned(),
|
||||
message,
|
||||
hint,
|
||||
}
|
||||
}
|
||||
|
||||
/// Map an engine error onto a stable code plus an actionable hint.
|
||||
#[must_use]
|
||||
pub fn from_core(err: &JmoveError) -> Self {
|
||||
let (code, hint) = match err {
|
||||
JmoveError::Io(_) => ("IO_ERROR", "check file permissions and disk space"),
|
||||
JmoveError::InvalidArgument(_) => (
|
||||
"INVALID_ARGUMENT",
|
||||
"paths must be inside the project root given by --root",
|
||||
),
|
||||
JmoveError::StaleIndex(_) => (
|
||||
"STALE_INDEX",
|
||||
"rerun the command; the index is rebuilt on every run",
|
||||
),
|
||||
JmoveError::PlanRejected(_) => (
|
||||
"PLAN_REJECTED",
|
||||
"run `jmove check --json` to inspect the import graph",
|
||||
),
|
||||
};
|
||||
Self::new(code, err.to_string(), Some(hint.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
/// One rewritten import inside a changed file.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Change {
|
||||
/// 1-based line of the rewritten specifier.
|
||||
pub line: usize,
|
||||
/// Specifier text before the move.
|
||||
pub old: String,
|
||||
/// Specifier text after the move.
|
||||
pub new: String,
|
||||
}
|
||||
|
||||
/// A file whose imports were rewritten, with line-level change details.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ChangedFile {
|
||||
/// Project-relative path of the importer.
|
||||
pub path: String,
|
||||
/// Rewritten specifiers, in source order.
|
||||
pub changes: Vec<Change>,
|
||||
}
|
||||
|
||||
/// Success payload of `mv --json` (flattened under `status: "ok"`).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MvData {
|
||||
/// Project-relative path the file moved from.
|
||||
pub source: String,
|
||||
/// Project-relative path the file moved to.
|
||||
pub target: String,
|
||||
/// Importer files touched by the move.
|
||||
pub changed_files: Vec<ChangedFile>,
|
||||
/// Number of files moved (always 1 in Phase 1).
|
||||
pub moved: usize,
|
||||
/// Total specifiers rewritten across all importers.
|
||||
pub updated_imports: usize,
|
||||
}
|
||||
|
||||
impl MvData {
|
||||
/// Assemble the payload from an applied plan and its change details.
|
||||
#[must_use]
|
||||
pub fn new(plan: &MovePlan, changed_files: Vec<ChangedFile>) -> Self {
|
||||
Self {
|
||||
source: plan.source.display().to_string(),
|
||||
target: plan.target.display().to_string(),
|
||||
changed_files,
|
||||
moved: 1,
|
||||
updated_imports: plan.rewrites.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dry-run payload of `mv --dry-run --json` (flattened under
|
||||
/// `status: "dry_run"`).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MvDryRunData {
|
||||
/// Project-relative path that would move.
|
||||
pub would_move: String,
|
||||
/// Project-relative destination that would be created.
|
||||
pub target: String,
|
||||
/// Specifiers that would be rewritten.
|
||||
pub would_update: usize,
|
||||
/// Importer files that would be touched, sorted.
|
||||
pub affected_files: Vec<String>,
|
||||
/// Unified diff (rewrites + rename) of the whole plan.
|
||||
pub diff: String,
|
||||
}
|
||||
|
||||
impl MvDryRunData {
|
||||
/// Assemble the preview payload from a plan and its rendered diff.
|
||||
#[must_use]
|
||||
pub fn new(plan: &MovePlan, diff: String) -> Self {
|
||||
Self {
|
||||
would_move: plan.source.display().to_string(),
|
||||
target: plan.target.display().to_string(),
|
||||
would_update: plan.rewrites.len(),
|
||||
affected_files: output::group_by_file(&plan.rewrites)
|
||||
.into_iter()
|
||||
.map(|(file, _)| file.display().to_string())
|
||||
.collect(),
|
||||
diff,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One unresolvable relative import found by `check`.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BrokenImport {
|
||||
/// Project-relative file declaring the import.
|
||||
pub file: String,
|
||||
/// 1-based line of the specifier.
|
||||
pub line: usize,
|
||||
/// Specifier text as written.
|
||||
pub import: String,
|
||||
/// Stable reason code, currently always `"file_not_found"`.
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
/// Success payload of `check --json` (flattened under the envelope).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CheckData {
|
||||
/// Broken imports, sorted by file then line.
|
||||
pub broken_imports: Vec<BrokenImport>,
|
||||
/// Number of broken imports (kept as an explicit counter for agents).
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
/// Serialize `value` as pretty JSON to stdout.
|
||||
pub fn print<T: Serialize>(value: &T) {
|
||||
let _ = value;
|
||||
todo!("cli agent: println!(serde_json::to_string_pretty)")
|
||||
match serde_json::to_string_pretty(value) {
|
||||
Ok(text) => println!("{text}"),
|
||||
Err(err) => eprintln!("jmove: failed to serialize JSON output: {err}"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue