chore: project skeleton — module contracts, docs plan, GPLv3

This commit is contained in:
loki5512344 2026-09-09 21:43:42 +02:00
commit a33ed2eb30
Signed by: boba
GPG key ID: 253067914055423B
20 changed files with 2220 additions and 0 deletions

35
src/core/apply.rs Normal file
View file

@ -0,0 +1,35 @@
//! Atomic apply with rollback, plus unified-diff rendering for dry-run.
//!
//! Order matters: specifier rewrites are applied to importer files first
//! (each written atomically via temp-file + rename), and the actual
//! `source -> target` rename happens last. Any failure mid-way triggers
//! rollback of everything already written.
use std::path::Path;
use crate::core::JmoveResult;
use crate::core::plan::MovePlan;
/// Summary of a successfully applied plan.
#[derive(Debug, Clone)]
pub struct Applied {
/// Number of files whose imports were rewritten.
pub files_rewritten: usize,
/// The moved file's new project-relative path.
pub new_path: std::path::PathBuf,
}
/// Apply `plan` under `root` atomically (see module docs). Rollback is
/// best-effort: on restore failure the error message states which files
/// need manual recovery.
pub fn apply(root: &Path, plan: &MovePlan) -> JmoveResult<Applied> {
let _ = (root, plan);
todo!("index agent: atomic apply + rollback")
}
/// Render the plan as a unified diff (rewrites + file rename) for dry-run.
#[must_use]
pub fn render_diff(root: &Path, plan: &MovePlan) -> JmoveResult<String> {
let _ = (root, plan);
todo!("index agent: diff rendering via `similar`")
}

79
src/core/index.rs Normal file
View file

@ -0,0 +1,79 @@
//! Project indexing: gitignore-aware filesystem scan plus the import graph.
//!
//! Built fresh on every command (disk cache is Phase 2). `ignore::WalkBuilder`
//! handles `.gitignore`/hidden-file rules; every indexed TS/JS source file is
//! parsed through [`crate::parser`] and its specifiers resolved through
//! [`crate::parser::resolve`].
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::core::JmoveResult;
use crate::parser::ImportRecord;
/// Indexed source files with O(1) membership lookups.
#[derive(Debug, Default)]
pub struct FileSet {
paths: HashSet<PathBuf>,
}
impl FileSet {
/// Add a normalized project-relative path; `false` if already present.
pub fn add(&mut self, path: PathBuf) -> bool {
self.paths.insert(path)
}
/// Whether `path` is a known indexed source file.
#[must_use]
pub fn contains(&self, path: &Path) -> bool {
self.paths.contains(path)
}
/// All files in deterministic sorted order (stable for tests and diffs).
#[must_use]
pub fn sorted(&self) -> Vec<PathBuf> {
let mut v: Vec<PathBuf> = self.paths.iter().cloned().collect();
v.sort();
v
}
}
/// One import occurrence plus the project file it resolves to.
/// `target: None` means "external" — a bare package specifier or a path
/// that does not exist in the index.
#[derive(Debug, Clone)]
pub struct ResolvedImport {
/// Raw record from the parser (specifier text + byte span).
pub record: ImportRecord,
/// Project-relative resolved file, if any.
pub target: Option<PathBuf>,
}
/// Full in-memory project index: file set and forward import edges.
#[derive(Debug, Default)]
pub struct Index {
/// Absolute project root the index was built for.
pub root: PathBuf,
/// All indexed source files.
pub files: FileSet,
/// For each file, the imports it declares (in source order).
pub imports: HashMap<PathBuf, Vec<ResolvedImport>>,
}
impl Index {
/// Scan `root`, parse every supported source file and build the graph.
/// Unparseable files are skipped, not fatal.
pub fn build(root: &Path) -> JmoveResult<Self> {
let _ = root;
todo!(
"index agent: scan with `ignore`, parse via crate::parser, resolve via parser::resolve"
)
}
/// Reverse edge lookup: every indexed file that imports `target`.
#[must_use]
pub fn importers_of(&self, target: &Path) -> Vec<PathBuf> {
let _ = target;
todo!("index agent: reverse-edge lookup")
}
}

103
src/core/mod.rs Normal file
View file

@ -0,0 +1,103 @@
//! Core engine: project indexing, dependency graph, move planning and
//! atomic apply with rollback.
//!
//! Path convention used across the crate: every `PathBuf` produced by
//! `jmove` is **project-root-relative, normalized** (no `.`/`..` segments).
//! Use [`normalize_rel_path`] to canonicalize paths coming from users or
//! from OS walking.
pub mod apply;
pub mod index;
pub mod plan;
use std::ffi::OsStr;
use std::io;
use std::path::{Component, Path, PathBuf};
use thiserror::Error;
/// Crate-wide error type surfaced to the CLI layer.
#[derive(Debug, Error)]
pub enum JmoveError {
/// Filesystem or IO failure.
#[error("io error: {0}")]
Io(#[from] io::Error),
/// The user supplied a path that is invalid for the requested operation.
#[error("invalid argument: {0}")]
InvalidArgument(String),
/// Project index is stale (a file vanished or was moved externally).
#[error("index is stale: {0}")]
StaleIndex(String),
/// The planned move cannot be applied safely.
#[error("plan rejected: {0}")]
PlanRejected(String),
}
/// Result alias used throughout the crate.
pub type JmoveResult<T> = Result<T, JmoveError>;
/// Normalize a project-relative path: strip `.` segments, collapse `..`
/// where possible and reject paths that escape the project root.
/// Returns `None` if the result would be empty, absolute or above the root.
///
/// # Examples
///
/// ```
/// use std::path::Path;
/// use jmove::core::normalize_rel_path;
///
/// assert_eq!(
/// normalize_rel_path(Path::new("./src/../utils/foo.ts")),
/// Some(PathBuf::from("utils/foo.ts"))
/// );
/// assert_eq!(normalize_rel_path(Path::new("../outside")), None);
/// ```
#[must_use]
pub fn normalize_rel_path(path: &Path) -> Option<PathBuf> {
let mut stack: Vec<&OsStr> = Vec::new();
for comp in path.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
if stack.pop().is_none() {
return None; // would escape the project root
}
}
Component::Normal(piece) => stack.push(piece),
// Absolute paths and Windows prefixes are not project-relative.
Component::RootDir | Component::Prefix(_) => return None,
}
}
(!stack.is_empty()).then(|| stack.iter().collect::<PathBuf>())
}
#[cfg(test)]
mod tests {
use super::normalize_rel_path;
use std::path::{Path, PathBuf};
#[test]
fn normalizes_dots_and_parent_dirs() {
assert_eq!(
normalize_rel_path(Path::new("./src/../utils/foo.ts")),
Some(PathBuf::from("utils/foo.ts"))
);
assert_eq!(
normalize_rel_path(Path::new("a/b/c/../../d.ts")),
Some(PathBuf::from("a/d.ts"))
);
}
#[test]
fn rejects_escaping_and_empty_paths() {
assert_eq!(normalize_rel_path(Path::new("../outside")), None);
assert_eq!(normalize_rel_path(Path::new("a/../../outside")), None);
assert_eq!(normalize_rel_path(Path::new("")), None);
assert_eq!(normalize_rel_path(Path::new("./")), None);
}
#[test]
fn rejects_absolute_paths() {
assert_eq!(normalize_rel_path(Path::new("/etc/passwd")), None);
}
}

46
src/core/plan.rs Normal file
View file

@ -0,0 +1,46 @@
//! Move planning: decide which import specifiers must be rewritten.
//!
//! A plan is pure data (no disk writes), so dry-run and `--json` can render
//! it without touching the filesystem.
use std::ops::Range;
use std::path::Path;
use std::path::PathBuf;
use crate::core::JmoveResult;
use crate::core::index::Index;
/// One in-file replacement of an import specifier. Only the specifier text
/// between the quotes is touched — the statement layout is never reformatted.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rewrite {
/// Project-relative file to modify.
pub file: PathBuf,
/// Byte range of the old specifier text (without quotes).
pub span: Range<usize>,
/// Specifier as currently written.
pub old_text: String,
/// Specifier after the move.
pub new_text: String,
}
/// Complete plan for moving `source` to `target`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MovePlan {
/// Project-relative path being moved.
pub source: PathBuf,
/// Project-relative destination path.
pub target: PathBuf,
/// Specifier rewrites, grouped per importer file.
pub rewrites: Vec<Rewrite>,
}
/// Compute the rewrite plan for `source -> target`.
///
/// Every indexed import whose resolved target is `source` gets a new
/// relative specifier computed from the *importer's* directory to `target`.
/// Rewrites whose result equals the old specifier are dropped.
pub fn plan_move(index: &Index, source: &Path, target: &Path) -> JmoveResult<MovePlan> {
let _ = (index, source, target);
todo!("index agent: implement planner incl. relative-specifier math")
}