1#![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
29const 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 read-only MCP stdio bridge
69
70OPTIONS:
71 --lib Create a library project instead of a binary
72 --path <dir> Source nichlink-core/build from a local checkout; for
73 explain, the host project to inspect (default: .)
74 --git <url> Source nichlink-core/build from a Git repository
75 --json Emit one JSON document on stdout instead of human text
76 (check, explain, grafts); check still exits non-zero on a
77 failed validation
78 --overlay With explain, render the static overlay projection of the
79 build's scope and declared cuts instead of one node
80 --editor <name> Editor to write snippets for: vscode (default), nvim
81 (LuaSnip), blink (blink.cmp) or auto (every editor
82 installed on this machine, in its user-level location;
83 fuzzy-matching engines need to be named explicitly)
84 --stdout Print the snippets instead of writing them (any editor)
85";
86
87pub fn main() -> Result<(), String> {
94 run(argv_strings(std::env::args_os())?)
95}
96
97pub fn argv_strings(
107 argv: impl IntoIterator<Item = std::ffi::OsString>,
108) -> Result<Vec<String>, String> {
109 argv.into_iter()
110 .map(|argument| {
111 argument
112 .into_string()
113 .map_err(|bad| format!("argument {bad:?} is not valid UTF-8"))
114 })
115 .collect()
116}
117
118pub fn run(argv: impl IntoIterator<Item = String>) -> Result<(), String> {
124 run_to(argv, &mut std::io::stdout())
125}
126
127pub fn run_to(argv: impl IntoIterator<Item = String>, out: &mut dyn Write) -> Result<(), String> {
136 let mut args = argv.into_iter().skip(1);
137 match args.next().as_deref() {
138 None | Some("--help") | Some("-h") | Some("help") => {
139 write!(out, "{USAGE}").map_err(|error| format!("cannot write usage: {error}"))?;
140 Ok(())
141 }
142 Some("new") => new_command::new(&mut args),
143 Some("check") => check_command::check(&mut args, out),
144 Some("build") => build_command::build(&mut args),
145 Some("snippets") => snippets_command::snippets(&mut args),
146 Some("explain") => explain::explain(&mut args, out),
147 Some("grafts") => grafts::grafts(&mut args, out),
148 Some("studio") => studio_command::studio(&mut args, out),
149 Some("mcp") => nichlink_mcp::run().map_err(|error| format!("mcp: {error}")),
150 Some(other) => Err(format!("unknown command '{other}' (see --help)")),
151 }
152}
153
154fn split_build_args(args: &[String]) -> (Option<String>, Vec<String>) {
159 match args.first() {
160 Some(first) if !first.starts_with('-') => (Some(first.clone()), args[1..].to_vec()),
161 _ => (None, args.to_vec()),
162 }
163}
164
165pub(crate) fn resolve_package(directory: &str) -> Result<(PathBuf, String), String> {
173 let manifest = std::fs::canonicalize(directory)
174 .map_err(|error| format!("cannot resolve {directory}: {error}"))?;
175 if !manifest.join("Cargo.toml").is_file() {
176 return Err(format!("{} has no Cargo.toml", manifest.display()));
177 }
178 let package = package_name(&manifest)?;
179 Ok((manifest, package))
180}
181
182pub(crate) fn build_out_dir(manifest: &Path) -> PathBuf {
191 manifest.join("target/nichlink/out")
192}
193
194fn registration_check(directory: &str) -> Result<String, String> {
198 let (manifest, package) = resolve_package(directory)?;
199 let out_dir = build_out_dir(&manifest);
200 nichlink_build_method::run_for(&manifest, &out_dir, &package)?;
201 Ok(package)
202}
203
204fn package_name(manifest: &Path) -> Result<String, String> {
219 let output = std::process::Command::new("cargo")
220 .args(["metadata", "--format-version", "1", "--no-deps"])
221 .arg("--manifest-path")
222 .arg(manifest.join("Cargo.toml"))
223 .output()
224 .map_err(|error| format!("cannot run cargo metadata: {error}"))?;
225 if !output.status.success() {
226 return Err(format!(
227 "cargo metadata failed for {}: {}",
228 manifest.display(),
229 String::from_utf8_lossy(&output.stderr).trim()
230 ));
231 }
232 let metadata: serde_json::Value = serde_json::from_slice(&output.stdout)
233 .map_err(|error| format!("cannot read cargo metadata output: {error}"))?;
234 let packages = metadata["packages"]
235 .as_array()
236 .ok_or_else(|| "cargo metadata reported no packages".to_owned())?;
237 let package = packages
243 .iter()
244 .find(|package| {
245 package["manifest_path"].as_str().is_some_and(|path| {
246 Path::new(path)
247 .parent()
248 .is_some_and(|parent| same_directory(parent, manifest))
249 })
250 })
251 .ok_or_else(|| {
252 format!(
253 "{} is not a package; cargo metadata listed {} workspace member(s)",
254 manifest.display(),
255 packages.len()
256 )
257 })?;
258 package["name"].as_str().map(str::to_owned).ok_or_else(|| {
259 format!(
260 "cargo metadata reported no package name for {}",
261 manifest.display()
262 )
263 })
264}
265
266fn same_directory(left: &Path, right: &Path) -> bool {
269 let canonical =
270 |path: &Path| std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
271 canonical(left) == canonical(right)
272}
273
274#[cfg(test)]
275#[path = "lib_tests.rs"]
276mod tests;