Skip to main content

nichlink_cli/
lib.rs

1//! NichLink command-line interface.
2//! NichLink 命令行界面。
3
4// The published surface must be readable on docs.rs without leaving the page,
5// so the lint is on for the whole crate; `clippy -D warnings` makes a new
6// undocumented public item a failure.
7// 发布表面必须能在 docs.rs 上不跳页读懂,因此 lint 开在整个 crate 上;
8// `clippy -D warnings` 会让新增的、没有文档的公开项变成失败。
9#![warn(missing_docs)]
10
11use std::io::Write;
12use std::path::{Path, PathBuf};
13
14#[path = "commands/build.rs"]
15mod build_command;
16#[path = "commands/check.rs"]
17mod check_command;
18#[path = "explain.rs"]
19mod explain;
20#[path = "grafts.rs"]
21mod grafts;
22#[path = "commands/new.rs"]
23mod new_command;
24#[path = "commands/snippets.rs"]
25mod snippets_command;
26#[path = "commands/studio.rs"]
27mod studio_command;
28
29// Split decision: the dispatch surface (`main`/`run`/`run_to`), the USAGE text
30// and the shared helpers stay here, so the public entry points keep their exact
31// paths and every command resolves one package root and one output directory.
32// Each subcommand implementation moved to `commands/<name>.rs` and is mounted
33// with `#[path]`, matching the workspace-wide module-mounting rule. The tests
34// moved to `lib_tests.rs` so this page stays a dispatch table rather than a
35// 900-line file.
36// 拆分决定:分发表面(`main`/`run`/`run_to`)、USAGE 文本与共用辅助函数留在这里,
37// 因此公开入口保持原路径,且每条命令解析同一个包根与输出目录。各子命令实现移到
38// `commands/<name>.rs` 并用 `#[path]` 挂载,符合全工作区的模块挂载规则。测试移到
39// `lib_tests.rs`,使本页保持为一张分发表,而不是 900 行的文件。
40
41const USAGE: &str = "\
42nichlink — NichLink command-line interface
43
44USAGE:
45    nichlink new <name> [--lib] [--path <workspace> | --git <url>]
46    nichlink check [path] [--json]
47    nichlink build [path] [cargo options]
48    nichlink snippets [path] [--editor vscode|nvim|blink|auto] [--stdout]
49    nichlink explain <node-id|logical/path> [--path <dir>] [--json]
50    nichlink explain --overlay [--path <dir>] [--json]
51    nichlink grafts [path] [--json]
52    nichlink studio [path]
53    nichlink mcp
54
55COMMANDS:
56    new       Create a NichLink host project in ./<name>
57    check     Run the registration discovery and validation pass without compiling
58    build     Validate the registration tree, then run cargo build
59    snippets  Inject the face-field editor snippets (VS Code project file, or
60              the LuaSnip file Neovim loads)
61    explain   Resolve a node id or logical path and report its identity, build
62              scope, pruning, and the declared graft cuts that name it; with
63              --overlay, render the static overlay projection of every slot
64    grafts    List every .nichlink/external-grafts/*/graft.plan, with its
65              selector, target path, graft, full flag, and whether the host
66              entry declares that slot (read-only)
67    studio    Launch the Studio TUI for the current project, or for `path`
68    mcp       Run the MCP stdio bridge: source and registry queries, plus
69              authoring writes that preview unless `apply: true`
70
71OPTIONS:
72    --lib             Create a library project instead of a binary
73    --path <dir>      Source nichlink-core/build from a local checkout; for
74                      explain, the host project to inspect (default: .)
75    --git <url>       Source nichlink-core/build from a Git repository
76    --json            Emit one JSON document on stdout instead of human text
77                      (check, explain, grafts); check still exits non-zero on a
78                      failed validation
79    --overlay         With explain, render the static overlay projection of the
80                      build's scope and declared cuts instead of one node
81    --editor <name>   Editor to write snippets for: vscode (default), nvim
82                      (LuaSnip), blink (blink.cmp) or auto (every editor
83                      installed on this machine, in its user-level location;
84                      fuzzy-matching engines need to be named explicitly)
85    --stdout          Print the snippets instead of writing them (any editor)
86";
87
88/// Entry point for the `nichlink` binary: dispatch this process's own argv.
89/// `nichlink` 二进制的入口:分发本进程自己的 argv。
90///
91/// Reads the real process arguments, writes the command's report to stdout, and
92/// returns a failure as `Err` so the binary decides the exit code.
93/// 读取真实进程参数,把命令报告写到 stdout,失败以 `Err` 返回,由二进制决定退出码。
94pub fn main() -> Result<(), String> {
95    run(argv_strings(std::env::args_os())?)
96}
97
98/// Convert process arguments to text, naming the first that is not UTF-8.
99/// 把进程参数转成文本,并点名第一个不是 UTF-8 的参数。
100///
101/// An argument on Linux may be any byte string, and `std::env::args()` *unwraps* the
102/// conversion: `nichlink check "/tmp/proj\xff"` died with a Rust backtrace instead of
103/// printing a usage error, which is not what a command-line tool owes a caller.
104/// Linux 上的参数可以是任意字节串,而 `std::env::args()` 会对转换 **unwrap**:
105/// `nichlink check "/tmp/proj\xff"` 会带着 Rust backtrace 死掉,而不是打印一条用法错误——
106/// 这不是命令行工具该给调用方的答复。
107pub fn argv_strings(
108    argv: impl IntoIterator<Item = std::ffi::OsString>,
109) -> Result<Vec<String>, String> {
110    argv.into_iter()
111        .map(|argument| {
112            argument
113                .into_string()
114                .map_err(|bad| format!("argument {bad:?} is not valid UTF-8"))
115        })
116        .collect()
117}
118
119/// Dispatch one command from an argv-style iterator (the program name is
120/// consumed and ignored). Shared by the `nichlink` and `cargo-nichlink`
121/// binaries, and writes its reports to process stdout.
122/// 从 argv 风格的迭代器分发一条命令(程序名会被消耗忽略)。`nichlink` 与
123/// `cargo-nichlink` 两个二进制共用,报告写到进程 stdout。
124pub fn run(argv: impl IntoIterator<Item = String>) -> Result<(), String> {
125    run_to(argv, &mut std::io::stdout())
126}
127
128/// The same dispatch as [`run`], against an explicit sink.
129/// 与 [`run`] 相同的分发,但写到显式指定的输出。
130///
131/// The operator commands (`check --json`, `explain`, `grafts`) exist to be read
132/// by a machine, so their document has to be assertable without redirecting the
133/// process's stdout from a test: this entry point is what the tests drive.
134/// 操作命令(`check --json`、`explain`、`grafts`)存在的意义就是被机器读取,因此它们
135/// 的文档必须能在不重定向进程 stdout 的情况下被测试断言:测试驱动的就是这个入口。
136pub fn run_to(argv: impl IntoIterator<Item = String>, out: &mut dyn Write) -> Result<(), String> {
137    let mut args = argv.into_iter().skip(1);
138    match args.next().as_deref() {
139        None | Some("--help") | Some("-h") | Some("help") => {
140            write!(out, "{USAGE}").map_err(|error| format!("cannot write usage: {error}"))?;
141            Ok(())
142        }
143        Some("new") => new_command::new(&mut args),
144        Some("check") => check_command::check(&mut args, out),
145        Some("build") => build_command::build(&mut args),
146        Some("snippets") => snippets_command::snippets(&mut args),
147        Some("explain") => explain::explain(&mut args, out),
148        Some("grafts") => grafts::grafts(&mut args, out),
149        Some("studio") => studio_command::studio(&mut args, out),
150        Some("mcp") => nichlink_mcp::run().map_err(|error| format!("mcp: {error}")),
151        Some(other) => Err(format!("unknown command '{other}' (see --help)")),
152    }
153}
154
155/// Split build args into an optional leading path and the remaining cargo
156/// options. The path must come first; anything after it is passed verbatim.
157/// 将 build 参数拆成可选的首个路径和其余 cargo 选项。路径必须在前,
158/// 之后的所有内容原样透传。
159fn split_build_args(args: &[String]) -> (Option<String>, Vec<String>) {
160    match args.first() {
161        Some(first) if !first.starts_with('-') => (Some(first.clone()), args[1..].to_vec()),
162        _ => (None, args.to_vec()),
163    }
164}
165
166/// Resolve one host project directory into its canonical root and the package
167/// name Cargo answers for it.
168/// 把一个宿主项目目录解析成规范根目录与 Cargo 为该包给出的包名。
169///
170/// The package name is the NodeId namespace, so every operator command has to
171/// read it from the same authority before it can name a face. That authority is
172/// `nichlink_build_method::package_name`, shared with the MCP bridge, which
173/// reports the same identities.
174/// 包名即 NodeId 命名空间,因此每条操作命令都必须先向同一权威读取它,才能命名一个面。该权威
175/// 是 `nichlink_build_method::package_name`,与报告同一批身份的 MCP 桥共用。
176pub(crate) fn resolve_package(directory: &str) -> Result<(PathBuf, String), String> {
177    let manifest = std::fs::canonicalize(directory)
178        .map_err(|error| format!("cannot resolve {directory}: {error}"))?;
179    if !manifest.join("Cargo.toml").is_file() {
180        return Err(format!("{} has no Cargo.toml", manifest.display()));
181    }
182    let package = nichlink_build_method::package_name(&manifest.join("Cargo.toml"))?;
183    Ok((manifest, package))
184}
185
186/// The directory the build publishes its generated plan and manifest output
187/// into, relative to a package root.
188/// 构建发布生成计划与清单产物的目录,相对包根。
189///
190/// `explain` reads `source_scope.tsv` and `pruning_manifest.tsv` from here; the
191/// path is the same one `registration_check` writes.
192/// `explain` 从这里读 `source_scope.tsv` 与 `pruning_manifest.tsv`;该路径与
193/// `registration_check` 写出的相同。
194pub(crate) fn build_out_dir(manifest: &Path) -> PathBuf {
195    manifest.join("target/nichlink/out")
196}
197
198/// Run registration discovery and validation for the host project at
199/// `directory`, returning the package name on success.
200/// 为 `directory` 处的宿主项目运行注册发现与校验,成功时返回包名。
201fn registration_check(directory: &str) -> Result<String, String> {
202    let (manifest, package) = resolve_package(directory)?;
203    let out_dir = build_out_dir(&manifest);
204    nichlink_build_method::run_for(&manifest, &out_dir, &package)?;
205    Ok(package)
206}
207
208#[cfg(test)]
209#[path = "lib_tests.rs"]
210mod tests;