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
80 changes: 68 additions & 12 deletions doc/DESIGN.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,78 @@
# mdref design document

mdref is a command-line tool for managing references of Markdown files.
mdref is a command-line tool and library for discovering, moving, and renaming Markdown references.

This document outlines the design and architecture of mdref.
This document describes the system as it exists today, the boundaries that are intentionally in scope, and future candidates that are not implemented yet. Read it together with [README.md](../README.md) for user-facing usage and [DirectoryMove.md](./DirectoryMove.md) for the directory-move algorithm.

## Functionality Design
## Current implementation

Basic operations include:
### Primary workflows

- find: Find all references to a Markdown file.
- move: Move a Markdown file and update all references.
- `find`: find inbound references to a Markdown file and list outbound links inside that file.
- `mv`: move a Markdown file or directory and rewrite affected local Markdown links.
- `rename`: rename a file in place by delegating to `mv` with a new filename in the same directory.

Other potential features:
### Layering

- validate: Validate that all references in Markdown files are valid.
- report: Generate a report of all Markdown files and their references.
- CLI entrypoints in `src/main.rs` and `src/commands/*` own argument parsing, progress display, and human or JSON rendering.
- The public library surface in `src/lib.rs` exposes `find_references`, `find_references_with_progress`, `mv`, `mv_with_progress`, `rename`, and `rename_with_progress`.
- Core behavior lives under `src/core`:
- `find.rs` parses Markdown and locates references.
- `mv.rs` validates paths, plans rewrites, executes moves, and coordinates rollback.
- `rename.rs` is a semantic wrapper around `mv`.
- `model/*` contains shared data structures such as move previews, replacements, and transactions.

Other features:
### Reference discovery model

- gitignore support: Ignore files/directories based on .gitignore patterns.
- atomic operations: Ensure move operations are atomic to prevent broken references.
- Discovery is limited to Markdown files with the `.md` extension.
- Directory scans use standard ignore handling through `.gitignore` and related ignore files, and this still applies when the root is not itself a Git repository.
- `find` returns two views of the same target:
- inbound references from other Markdown files under the chosen root
- outbound links found inside the target file
- Supported local reference forms include inline links and link reference definitions.
- External URLs such as `https://`, `mailto:`, and similar schemes are treated as non-local and are never rewritten.
- Pure fragment links such as `#section` are not rewritten. File links with fragments keep the fragment.

### Move and rename model

- A file move updates:
- other Markdown files that point to the moved file
- links inside the moved file whose relative target changes after the move
- A directory move is planned before any mutation:
- external files pointing into the moved directory are rewritten
- moved Markdown files pointing outside the directory are rewritten
- links between files that move together are usually left unchanged because their relative positions do not change
- `rename` is implemented as a same-directory move and therefore shares validation, rewrite planning, dry-run behavior, and rollback semantics with `mv`.
- `--dry-run` computes the full move preview without modifying files.
- Execution uses a transaction-like flow: plan first, then mutate, then attempt rollback if a later step fails.

### Output contracts

- Human output is intended for interactive use:
- `find` prints separate sections for references and links.
- `mv` and `rename` print a summary for real runs.
- dry-run mode prints a preview of the move and each planned replacement.
- JSON output is available for `find`, `mv`, and `rename` and is intended for automation.
- Successful `find` output includes `operation`, `target`, `references`, and `links`.
- Successful `mv` output includes `operation`, `source`, `destination`, `root`, `dry_run`, and `changes`.
- Successful `rename` output includes `operation`, `source`, `new_name`, `destination`, `root`, `dry_run`, and `changes`.
- Each change entry includes the affected `path`, a `kind` (`reference_update` or `moved_file_update`), and line or column-based replacements.
- When JSON output is requested, command failures are also emitted as JSON on stderr with command context and an `error` message.

## Known boundaries

- The project is focused on local Markdown references. It does not try to validate or rewrite arbitrary text formats or non-Markdown documents.
- Only `.md` files discovered by the scan participate in reference discovery and rewrite planning.
- Ignored files and directories are intentionally skipped during scanning, so references inside ignored Markdown files are not updated.
- Path resolution prefers canonicalized real paths when possible. This helps with symlink-aware comparisons and paths that do not exist yet, but the exact filesystem behavior still depends on the host platform.
- Rollback is best-effort rather than a hard atomicity guarantee. The code attempts to restore moved paths and rewritten file contents, but filesystem boundaries, permissions, and platform-specific rename semantics can still limit recovery.
- Directory move behavior is described in more detail in [DirectoryMove.md](./DirectoryMove.md). This document stays at the architectural level.

## Future candidates

The following items are not part of the current implementation and should be treated as roadmap ideas, not existing capabilities:

- `validate`: verify that local Markdown references resolve successfully.
- `report`: generate a broader project-level summary of Markdown files and relationships.
- Additional reporting or machine-readable formats beyond today's human and JSON outputs.
- Broader documentation around path resolution, symlink handling, and rollback guarantees as dedicated reference material.
31 changes: 0 additions & 31 deletions doc/ProjectReview-2026-04-25.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,6 @@

## 优先级最高的改进点

### 1. 同步设计文档与当前实现,避免文档成为误导源

优先级:高

观察依据:

- [doc/DESIGN.md](./DESIGN.md) 目前只列出 `find`、`move`,并把 `validate`、`report` 写成潜在特性。
- 但当前实现已经明确支持 `rename`、`dry-run`、JSON 输出、目录移动、事务式回滚、`.gitignore` 感知扫描等能力。
- [README.md](../README.md) 已描述部分能力,但设计文档没有同步到位,容易让贡献者对系统边界产生错误判断。

建议方向:

- 重写 [doc/DESIGN.md](./DESIGN.md),改成“当前实现 + 已知边界 + 后续路线图”的结构。
- 把“已实现能力”和“未来候选能力”明确分开,不要混写在同一层级。
- 补充 `rename`、目录移动、回滚模型、JSON 输出契约、忽略规则等内容。

### 2. 拆分 [src/core/mv.rs](../src/core/mv.rs),降低核心变更风险

优先级:高
Expand All @@ -66,21 +50,6 @@
- 比较自然的边界是:`validate`、`plan`、`apply`、`case_only`、`preview`、`transaction`。
- 第一阶段只做模块搬迁和命名收敛,不改行为;第二阶段再考虑局部抽象优化。

### 3. 收敛 CLI 层重复逻辑,减少命令实现的平行分叉

优先级:中高

观察依据:

- [src/commands/find.rs](../src/commands/find.rs)、[src/commands/mv.rs](../src/commands/mv.rs)、[src/commands/rename.rs](../src/commands/rename.rs) 都在重复创建 `ProgressBar`、设置样式、结束进度条、在 human/json 输出之间分支。
- JSON 序列化的公共部分已经在 [src/commands/mod.rs](../src/commands/mod.rs) 里抽了一部分,但运行流程仍然分散。
- `rename` 与 `mv` 的命令层尤其相似,只是输入语义不同。

建议方向:

- 在 `commands` 下新增轻量辅助模块,例如 `progress.rs` 或 `ui.rs`。
- 抽出统一的进度条创建逻辑与“完成后清理”逻辑。
- 对 `mv` / `rename` 的 dry-run 与 json 输出路径做更强的共享封装,减少双份维护。

### 4. 收敛公共 API 表面积,重新评估 `*_with_progress` 这一层包装

Expand Down
18 changes: 3 additions & 15 deletions src/commands/find.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
use std::io::Write;

use indicatif::{ProgressBar, ProgressStyle};
use mdref::{MdrefError, Reference, Result, find_links, find_references_with_progress};
use serde::Serialize;

use super::OutputFormat;
use super::{OutputFormat, progress};

pub fn run(
path: String,
Expand All @@ -26,22 +25,11 @@ fn run_with_writer<W: Write>(
let root_path = root_dir.unwrap_or_else(|| ".".to_string());

// Find references to the specified file.
let progress = if show_progress {
let progress_bar = ProgressBar::new_spinner();
progress_bar.set_style(
ProgressStyle::with_template("{spinner:.green} [{pos}/{len}] {msg}")
.expect("valid template"),
);
Some(progress_bar)
} else {
None
};
let progress = progress::create_spinner(show_progress);

let references = find_references_with_progress(&path, &root_path, progress.as_ref())?;

if let Some(progress_bar) = &progress {
progress_bar.finish_and_clear();
}
progress::finish(&progress);

// Find all links within the specified file.
let links = find_links(&path)?;
Expand Down
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use serde::Serialize;

mod find;
mod mv;
pub(crate) mod progress;
mod rename;

#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
Expand Down
22 changes: 4 additions & 18 deletions src/commands/mv.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
use std::io::Write;

use indicatif::{ProgressBar, ProgressStyle};
use mdref::{Result, core::mv::preview_move_with_progress, mv_with_progress};
use serde::Serialize;

use crate::commands::{
OutputFormat, json_move_changes, write_json_output, write_move_preview_human,
OutputFormat, json_move_changes, progress, write_json_output, write_move_preview_human,
};

pub fn run(
Expand Down Expand Up @@ -39,16 +38,7 @@ fn run_with_writer<W: Write>(
) -> Result<()> {
let root = root.unwrap_or_else(|| ".".to_string());

let progress = if show_progress && !dry_run {
let progress_bar = ProgressBar::new_spinner();
progress_bar.set_style(
ProgressStyle::with_template("{spinner:.green} [{pos}/{len}] {msg}")
.expect("valid template"),
);
Some(progress_bar)
} else {
None
};
let progress = progress::create_spinner(show_progress && !dry_run);

match format {
OutputFormat::Human => {
Expand All @@ -60,9 +50,7 @@ fn run_with_writer<W: Write>(
writeln!(writer, "Move {source} -> {dest} in {root}")?;
let result = mv_with_progress(&source, &dest, &root, false, progress.as_ref());

if let Some(progress_bar) = &progress {
progress_bar.finish_and_clear();
}
progress::finish(&progress);

result
}
Expand All @@ -73,9 +61,7 @@ fn run_with_writer<W: Write>(
mv_with_progress(&source, &dest, &root, false, progress.as_ref())?;
}

if let Some(progress_bar) = &progress {
progress_bar.finish_and_clear();
}
progress::finish(&progress);

let payload = MoveCommandOutput {
operation: "mv",
Expand Down
25 changes: 25 additions & 0 deletions src/commands/progress.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
use indicatif::{ProgressBar, ProgressStyle};

const SPINNER_TEMPLATE: &str = "{spinner:.green} [{pos}/{len}] {msg}";

/// Create an optional spinner progress bar.
///
/// Returns `Some(ProgressBar)` when `enabled` is `true`, `None` otherwise.
/// All command modules share the same spinner style, so this centralises the
/// template in one place.
pub fn create_spinner(enabled: bool) -> Option<ProgressBar> {
if !enabled {
return None;
}

let progress_bar = ProgressBar::new_spinner();
progress_bar.set_style(ProgressStyle::with_template(SPINNER_TEMPLATE).expect("valid template"));
Some(progress_bar)
}

/// Finish and clear the progress bar, if present.
pub fn finish(progress: &Option<ProgressBar>) {
if let Some(progress_bar) = progress {
progress_bar.finish_and_clear();
}
}
22 changes: 4 additions & 18 deletions src/commands/rename.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
use std::io::Write;

use indicatif::{ProgressBar, ProgressStyle};
use mdref::{Result, core::mv::preview_move_with_progress, rename_with_progress};
use serde::Serialize;

use crate::commands::{
OutputFormat, json_move_changes, write_json_output, write_move_preview_human,
OutputFormat, json_move_changes, progress, write_json_output, write_move_preview_human,
};

pub fn run(
Expand All @@ -32,16 +31,7 @@ fn run_with_writer<W: Write>(
let root_path = root.unwrap_or_else(|| ".".to_string());
let destination = std::path::Path::new(&old).with_file_name(&new);

let progress = if show_progress && !dry_run {
let progress_bar = ProgressBar::new_spinner();
progress_bar.set_style(
ProgressStyle::with_template("{spinner:.green} [{pos}/{len}] {msg}")
.expect("valid template"),
);
Some(progress_bar)
} else {
None
};
let progress = progress::create_spinner(show_progress && !dry_run);

match format {
OutputFormat::Human => {
Expand All @@ -53,9 +43,7 @@ fn run_with_writer<W: Write>(
writeln!(writer, "Rename {old} -> {new} in {root_path}")?;
let result = rename_with_progress(&old, &new, &root_path, false, progress.as_ref());

if let Some(progress_bar) = &progress {
progress_bar.finish_and_clear();
}
progress::finish(&progress);

result
}
Expand All @@ -66,9 +54,7 @@ fn run_with_writer<W: Write>(
rename_with_progress(&old, &new, &root_path, false, progress.as_ref())?;
}

if let Some(progress_bar) = &progress {
progress_bar.finish_and_clear();
}
progress::finish(&progress);

let payload = RenameCommandOutput {
operation: "rename",
Expand Down