nichlink-cli 0.1.3

NichLink command-line interface: project scaffolding, Studio, and the MCP bridge
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
//! End-to-end tests for the CLI dispatch surface.
//! CLI 分发表面的端到端测试。
//!
//! Split out of `lib.rs` (see the split note there): these tests drive the
//! public `run`/`run_to` entry points and the private helpers through `super`,
//! so they pin dispatch behaviour without making the dispatch page itself
//! exceed the file budget.
//! 从 `lib.rs` 拆出(见那里的拆分说明):这些测试经 `super` 驱动公开的
//! `run`/`run_to` 入口与私有辅助函数,因此钉住分发行为,又不让分发页本身超出
//! 文件预算。

use super::{run, run_to, split_build_args};
use nichlink::identity::NodeId;
use nichlink::plugin::graft_document::GraftPlanDocument;
use serde_json::Value;
use std::fs;
use std::path::PathBuf;

/// Drive the CLI against an in-memory sink so a test can read the exact
/// stdout document a machine would parse. `run` is the same dispatch with
/// process stdout; `run_to` exists only so the document is assertable.
/// 让 CLI 写到内存接收器,使测试能读到机器会解析的那份 stdout 文档。`run` 是
/// 同一个分发、写到进程 stdout;`run_to` 的存在只是为了让文档可被断言。
fn run_capture(args: &[&str]) -> (Result<(), String>, String) {
    let mut buffer = Vec::new();
    let result = run_to(args.iter().map(|value| (*value).to_owned()), &mut buffer);
    (result, String::from_utf8(buffer).expect("utf-8 output"))
}

/// A throwaway Cargo package with an entry and registration faces, so
/// `cargo metadata` answers a package name and the build sees a real tree.
/// 一次性的 Cargo 包,带入口与注册面,使 `cargo metadata` 能给出包名、构建能看到
/// 真实的树。
fn fixture_host(label: &str, entry: &str, faces: &[(&str, &str)]) -> PathBuf {
    let root = temporary_root(label);
    fs::create_dir_all(root.join("src")).expect("fixture src");
    fs::write(
        root.join("Cargo.toml"),
        format!("[package]\nname = \"{label}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
    )
    .expect("manifest");
    fs::write(root.join("src/lib.rs"), entry).expect("entry");
    for (relative, source) in faces {
        let path = root.join("src").join(relative);
        fs::create_dir_all(path.parent().expect("face parent")).expect("face directory");
        fs::write(&path, source).expect("face source");
    }
    root
}

/// The three-level tree the operator commands are tested against, matching
/// the logical paths the README's example uses. The macro names follow the
/// generated per-registry aliases (`control_object!`, `object_object!`),
/// because the build's own validation requires the parent-specific macro; a
/// `root_object!` child under a registry is reported as a mismatch and would
/// make the fixture an invalid host.
/// 操作命令测试所用的三层树,逻辑路径与 README 示例一致。宏名遵循生成的按注册机
/// 别名(`control_object!`、`object_object!`),因为构建自身的校验要求父级专用
/// 宏;注册机下用 `root_object!` 的子面会被报为不匹配,夹具就成了非法宿主。
fn control_tree() -> Vec<(&'static str, &'static str)> {
    vec![
        (
            "control/control.rs",
            "crate::root_object! {\n    kind: Control,\n    needs_registry: true,\n    parent: crate::root_node_id(env!(\"CARGO_PKG_NAME\")),\n}\n",
        ),
        (
            "control/object/object.rs",
            "crate::control_object! {\n    kind: Object,\n    parent: crate::control::NODE_ID,\n    needs_registry: true,\n}\n",
        ),
        (
            "control/object/button/button.rs",
            "crate::object_object! {\n    kind: Button,\n    parent: crate::control::object::NODE_ID,\n}\n",
        ),
    ]
}

const DECLARED_BUTTON: &str = "// host entry\nnichlink_run_method::static_graft_plan!(\n    FRAMEWORK,\n    cut \"root/control/object/button\" graft \"button_fast\",\n);\n";

/// `check --json` puts exactly one document on stdout, carries every
/// diagnostic the human frame would print, and still fails the command.
/// `check --json` 在 stdout 上只放一个文档,携带人类可读帧会打印的每条诊断,并且
/// 仍然让命令失败。
#[test]
fn check_json_emits_one_document_and_still_fails() {
    let root = fixture_host(
        "cli-check-json",
        "// host\n",
        &[
            (
                "one/one.rs",
                "crate::root_object! {\n    kind: One,\n    stable_name: \"dup\",\n}\n",
            ),
            (
                "two/two.rs",
                "crate::root_object! {\n    kind: Two,\n    stable_name: \"dup\",\n}\n",
            ),
        ],
    );
    let path = root.display().to_string();
    let (result, stdout) = run_capture(&["nichlink", "check", "--json", &path]);
    assert!(result.is_err(), "a duplicate stable_name must fail");
    assert_eq!(stdout.lines().count(), 1, "exactly one document: {stdout}");
    let document: Value = serde_json::from_str(stdout.trim()).expect("one JSON document");
    assert_eq!(document["schema"], "nichlink.build-diagnostics/1");
    assert!(document["count"].as_u64().expect("count") >= 1);
    assert!(
        document["diagnostics"]
            .as_array()
            .expect("diagnostics")
            .iter()
            .any(|diagnostic| diagnostic["message"]
                .as_str()
                .unwrap_or_default()
                .contains("duplicate stable_name")),
        "{document}"
    );
    fs::remove_dir_all(root).expect("cleanup");
}

/// Without `--json` the human output is byte-identical to the historical
/// `nichlink check: ok (<package>)` line, and the public `run` entry point
/// accepts the command.
/// 不带 `--json` 时人类输出与历史的 `nichlink check: ok (<package>)` 行逐字节
/// 一致,且公开的 `run` 入口接受该命令。
#[test]
fn check_without_json_keeps_the_human_line() {
    let root = fixture_host("cli-check-human", "// host\n", &[]);
    let path = root.display().to_string();
    let (result, stdout) = run_capture(&["nichlink", "check", &path]);
    assert!(result.is_ok(), "{result:?}");
    assert_eq!(stdout, "nichlink check: ok (cli-check-human)\n");
    assert!(
        run(["nichlink".to_owned(), "check".to_owned(), path]).is_ok(),
        "the public `run` entry point accepts check"
    );
    fs::remove_dir_all(root).expect("cleanup");
}

/// `explain` resolves a logical path and a node id to the same face, and
/// reports identity, build scope, and the declared cut that names it.
/// `explain` 把逻辑路径与节点 id 解析到同一个面,并报告身份、构建作用域与命名它
/// 的已声明切口。
#[test]
fn explain_json_reports_identity_scope_and_declared_grafts() {
    let root = fixture_host("cli-explain", DECLARED_BUTTON, &control_tree());
    let path = root.display().to_string();
    let (checked, check_stdout) = run_capture(&["nichlink", "check", &path]);
    assert!(checked.is_ok(), "{checked:?} {check_stdout}");
    let (result, stdout) = run_capture(&[
        "nichlink",
        "explain",
        "--json",
        "--path",
        &path,
        "root/control/object/button",
    ]);
    assert!(result.is_ok(), "{result:?} {stdout}");
    let document: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
    assert_eq!(document["resolved"], true);
    assert_eq!(document["node"]["kind"], "Button");
    assert_eq!(
        document["node"]["source"],
        "control/object/button/button.rs"
    );
    assert_eq!(document["node"]["registry_name"], "button");
    assert_eq!(document["node"]["parent"]["path"], "root/control/object");
    assert_eq!(document["scope"]["known"], true);
    assert_eq!(document["pruning"]["known"], true);
    assert_eq!(document["declared_grafts"]["count"], 1);
    assert_eq!(
        document["declared_grafts"]["cuts"][0]["graft"],
        "button_fast"
    );

    let id = document["node"]["id"].as_str().expect("node id").to_owned();
    let (result, stdout) = run_capture(&["nichlink", "explain", "--json", "--path", &path, &id]);
    assert!(result.is_ok(), "{result:?} {stdout}");
    let by_id: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
    assert_eq!(by_id["node"]["path"], "root/control/object/button");
    fs::remove_dir_all(root).expect("cleanup");
}

/// `explain` refuses to present build output that predates the sources.
/// `explain` 不会把早于当前源码的构建产物当作现状提供。
///
/// It used to serve the previous build's scope as `known: true`, so the same tree
/// answered differently depending on whether a `check` had happened to run in
/// between; output left behind by a *failed* check was served the same way. The
/// failing half is pinned in `build_method::scope_view` (the run publishes no
/// fingerprint); this pins the staleness half end to end, through the real
/// command.
/// 它过去会把上一次构建的作用域当作 `known: true` 提供,于是同一棵树会因期间是否恰好跑过
/// `check` 而给出不同答案;**失败**的 check 留下的产物也是这样被提供的。失败那一半钉在
/// `build_method::scope_view`(该次运行不发布指纹);这里端到端钉住陈旧那一半,走真实命令。
#[test]
fn explain_reports_an_unknown_scope_when_the_build_output_is_stale() {
    let root = fixture_host("cli-explain-stale", DECLARED_BUTTON, &control_tree());
    let path = root.display().to_string();
    let (checked, check_stdout) = run_capture(&["nichlink", "check", &path]);
    assert!(checked.is_ok(), "{checked:?} {check_stdout}");

    let query = [
        "nichlink",
        "explain",
        "--json",
        "--path",
        &path,
        "root/control/object/button",
    ];
    let (result, stdout) = run_capture(&query);
    assert!(result.is_ok(), "{result:?} {stdout}");
    let fresh: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
    assert_eq!(fresh["scope"]["known"], true, "{fresh}");
    assert_eq!(fresh["pruning"]["known"], true, "{fresh}");

    // A content change that keeps the host valid: the pin is about the token, not
    // about a semantic edit.
    // 一次仍然让宿主合法的内容变化:这条钉子关乎那枚凭据,而不是语义改动。
    let button = root.join("src/control/object/button/button.rs");
    let text = fs::read_to_string(&button).expect("button source");
    fs::write(&button, format!("// edited\n{text}")).expect("edited button");

    let (result, stdout) = run_capture(&query);
    assert!(result.is_ok(), "{result:?} {stdout}");
    let stale: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
    assert_eq!(
        stale["scope"]["known"], false,
        "a previous build must not answer for these sources: {stale}"
    );
    assert_eq!(stale["pruning"]["known"], false, "{stale}");
    assert_eq!(stale["resolved"], true, "the node itself is resolved live");

    // Re-publishing restores the answer, so the command is not simply pessimistic.
    // 重新发布即可恢复答案,因此本命令并非一律悲观。
    let (checked, check_stdout) = run_capture(&["nichlink", "check", &path]);
    assert!(checked.is_ok(), "{checked:?} {check_stdout}");
    let (_, stdout) = run_capture(&query);
    let current: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
    assert_eq!(current["scope"]["known"], true, "{current}");
    fs::remove_dir_all(root).expect("cleanup");
}

/// An unresolvable query is reported with its reason instead of a bare
/// failure, so an operator can correct the spelling.
/// 无法解析的查询连原因一起报告,而不是只报失败,让操作者能改正拼写。
#[test]
fn explain_reports_why_a_node_cannot_be_resolved() {
    let root = fixture_host(
        "cli-explain-missing",
        "// host\n",
        &[("one/one.rs", "crate::root_object! {\n    kind: One,\n}\n")],
    );
    let path = root.display().to_string();
    let (result, stdout) = run_capture(&[
        "nichlink",
        "explain",
        "--json",
        "--path",
        &path,
        "root/nope",
    ]);
    assert!(result.is_err());
    let document: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
    assert_eq!(document["resolved"], false);
    assert!(
        document["reason"]
            .as_str()
            .unwrap_or_default()
            .contains("root/nope"),
        "{document}"
    );
    fs::remove_dir_all(root).expect("cleanup");
}

/// `explain --json` emits one JSON document even when the package cannot be
/// resolved, so a machine reader is never handed an empty stdout; the human
/// path still writes nothing.
/// 包解析不出来时 `explain --json` 仍输出一个 JSON 文档,机器读者绝不会拿到空 stdout;
/// 人类可读路径仍然什么都不写。
#[test]
fn explain_json_reports_a_resolution_failure_as_json() {
    let missing = temporary_root("cli-explain-unresolvable").join("no-such-project");
    let path = missing.display().to_string();
    let (result, stdout) = run_capture(&[
        "nichlink",
        "explain",
        "--json",
        "--path",
        &path,
        "root/anything",
    ]);
    assert!(result.is_err(), "an unresolvable package must fail");
    let document: Value = serde_json::from_str(stdout.trim()).expect("one JSON document");
    assert_eq!(document["schema"], "nichlink.explain/1");
    assert_eq!(document["resolved"], false, "{document}");
    assert!(
        document["reason"]
            .as_str()
            .unwrap_or_default()
            .contains("cannot resolve"),
        "{document}"
    );

    let (result, stdout) = run_capture(&["nichlink", "explain", "--path", &path, "root/anything"]);
    assert!(result.is_err());
    assert!(
        stdout.is_empty(),
        "without --json stdout stays empty: {stdout}"
    );
}

/// `explain --overlay --json` emits the projection's own schema on the same
/// failure, carrying the reason instead of an empty stdout.
/// 同样的失败下 `explain --overlay --json` 输出投影自己的 schema,携带原因而不是空 stdout。
#[test]
fn explain_overlay_json_reports_a_resolution_failure_as_json() {
    let missing = temporary_root("cli-overlay-unresolvable").join("no-such-project");
    let path = missing.display().to_string();
    let (result, stdout) = run_capture(&[
        "nichlink",
        "explain",
        "--overlay",
        "--json",
        "--path",
        &path,
    ]);
    assert!(result.is_err(), "an unresolvable package must fail");
    let document: Value = serde_json::from_str(stdout.trim()).expect("one JSON document");
    assert_eq!(document["schema"], "nichlink.explain-overlay/1");
    assert_eq!(document["kind"], "static-projection");
    assert!(
        document["error"]
            .as_str()
            .unwrap_or_default()
            .contains("cannot resolve"),
        "{document}"
    );
}

/// `grafts` lists a readable plan with its selector, target path, graft,
/// `full`, and declared state, and reports an unreadable one with its
/// reason. Read-only: no file under the host changes.
/// `grafts` 列出一条可读计划的 selector、目标路径、graft、`full` 与声明状态,并把
/// 不可读的计划连原因一起报出。只读:宿主下没有文件被改动。
#[test]
fn grafts_json_lists_plans_and_declared_state() {
    let root = fixture_host("cli-grafts", DECLARED_BUTTON, &control_tree());
    let plan_dir = root.join(".nichlink/external-grafts/button_fast");
    fs::create_dir_all(&plan_dir).expect("plan directory");
    let target =
        NodeId::from_namespaced_path("cli-grafts", "control/object/button/button.rs", "Button");
    let document =
        GraftPlanDocument::new(target, "root/control/object/button", "button_fast", false);
    fs::write(plan_dir.join("graft.plan"), document.render()).expect("plan file");
    let broken = root.join(".nichlink/external-grafts/broken_graft");
    fs::create_dir_all(&broken).expect("broken directory");
    fs::write(broken.join("graft.plan"), "version=9\n").expect("broken plan");

    let path = root.display().to_string();
    let (result, stdout) = run_capture(&["nichlink", "grafts", "--json", &path]);
    assert!(result.is_ok(), "{result:?} {stdout}");
    let report: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
    assert_eq!(report["schema"], "nichlink.grafts/1");
    let plans = report["plans"].as_array().expect("plans");
    assert_eq!(plans.len(), 2);
    let button = plans
        .iter()
        .find(|plan| plan["selector"] == "button_fast")
        .expect("button plan");
    assert_eq!(button["target_path"], "root/control/object/button");
    assert_eq!(button["graft"], "button_fast");
    assert_eq!(button["full"], false);
    assert_eq!(button["declared"], true);
    assert_eq!(button["declared_by"]["graft"], "button_fast");
    let broken_row = plans
        .iter()
        .find(|plan| plan["selector"] == "broken_graft")
        .expect("broken plan");
    assert!(broken_row["error"].as_str().is_some(), "{broken_row}");
    fs::remove_dir_all(root).expect("cleanup");
}

/// A plans directory that exists and cannot be read is a failure, not "no
/// plans": the command's one answer is the declaration state of each plan, and
/// an unreadable directory leaves it with nothing to answer. The document still
/// reaches stdout, so a reader sees whatever was readable.
/// 存在却读不了的计划目录是失败,而不是"没有计划":本命令唯一的答案是每条计划的声明状态,
/// 而读不了的目录让它无话可答。文档仍会写到 stdout,因此读者能看到可读的部分。
#[test]
fn grafts_reports_an_unreadable_plans_directory() {
    let root = fixture_host("cli-grafts-unreadable", DECLARED_BUTTON, &control_tree());
    // A file where the plans directory belongs, so the path exists and this is
    // not the ordinary "no plans yet" case.
    // 计划目录的位置放一个文件:路径存在,因此这不是普通的"还没有计划"。
    fs::create_dir_all(root.join(".nichlink")).expect("nichlink directory");
    fs::write(root.join(".nichlink/external-grafts"), "not a directory").expect("blocking file");

    let path = root.display().to_string();
    let (result, stdout) = run_capture(&["nichlink", "grafts", "--json", &path]);
    let error = result.expect_err("an unreadable plans directory must fail");
    assert!(error.contains("external-grafts"), "{error}");
    let report: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
    assert_eq!(report["plans"].as_array().map(Vec::len), Some(0));

    fs::remove_dir_all(root).expect("cleanup");
}

/// A host whose source tree cannot be read is a failure too: the declaration
/// column would otherwise be guessed from an empty face list.
/// 读不了源码树的宿主同样是失败:否则声明那一列会由一个空的面清单猜出来。
#[test]
fn grafts_reports_a_host_whose_sources_cannot_be_read() {
    let root = temporary_root("cli-grafts-no-src");
    // Cargo needs a target, and this package has one that is not a library
    // target: NichLink's source root follows the **library** target, so a
    // bin-only package without `src/` keeps the conventional root and `grafts`
    // reports that it is absent. (A `[lib] path` outside `src/` used to stand in
    // for this, until the build learned to read that layout.)
    // Cargo 需要一个 target,而这个包的 target 不是库目标:NichLink 的源码根跟随**库**目标,
    // 因此没有 `src/` 的纯二进制包沿用约定根,`grafts` 报告它不存在。(过去用 `src/` 之外的
    // `[lib] path` 来代替这一情形,直到构建学会读那种布局。)
    fs::create_dir_all(root.join("app")).expect("binary directory");
    fs::write(root.join("app/main.rs"), "fn main() {}\n").expect("binary target");
    fs::write(
        root.join("Cargo.toml"),
        "[package]\nname = \"no-src\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[[bin]]\nname = \"no-src\"\npath = \"app/main.rs\"\n",
    )
    .expect("manifest");

    let path = root.display().to_string();
    let (result, _stdout) = run_capture(&["nichlink", "grafts", &path]);
    let error = result.expect_err("a host without sources cannot be answered about");
    assert!(error.contains("source tree"), "{error}");

    fs::remove_dir_all(root).expect("cleanup");
}

/// `explain --overlay` renders the static overlay projection: the declared
/// slot carries its replacement, and the plan records are listed.
/// `explain --overlay` 渲染静态覆盖投影:已声明槽位带出它的替换件,并列出计划记录。
#[test]
fn explain_overlay_renders_the_static_projection() {
    let root = fixture_host("cli-overlay", DECLARED_BUTTON, &control_tree());
    let plan_dir = root.join(".nichlink/external-grafts/button_fast");
    fs::create_dir_all(&plan_dir).expect("plan directory");
    let target =
        NodeId::from_namespaced_path("cli-overlay", "control/object/button/button.rs", "Button");
    let document =
        GraftPlanDocument::new(target, "root/control/object/button", "button_fast", false);
    fs::write(plan_dir.join("graft.plan"), document.render()).expect("plan file");

    let path = root.display().to_string();
    let (checked, check_stdout) = run_capture(&["nichlink", "check", &path]);
    assert!(checked.is_ok(), "{checked:?} {check_stdout}");
    let (result, stdout) = run_capture(&[
        "nichlink",
        "explain",
        "--overlay",
        "--json",
        "--path",
        &path,
    ]);
    assert!(result.is_ok(), "{result:?} {stdout}");
    let report: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
    assert_eq!(report["schema"], "nichlink.explain-overlay/1");
    assert_eq!(report["kind"], "static-projection");
    let button = report["slots"]
        .as_array()
        .expect("slots")
        .iter()
        .find(|slot| slot["path"] == "root/control/object/button")
        .expect("declared slot stays in the kept list");
    assert_eq!(button["replacement"]["graft"], "button_fast");
    // A parent face is not a declared slot but still ships, because the
    // selected child needs it; calling it "pruned" would misreport the tree.
    // 父面不是已声明槽位但仍然发布,因为被选中的子级需要它;说它"被剪掉"就是
    // 错误描述这棵树。
    assert!(
        report["slots"]
            .as_array()
            .expect("slots")
            .iter()
            .any(|slot| slot["path"] == "root/control/object" && slot["kept"] == true),
        "a needed parent stays in the kept list: {report}"
    );
    assert_eq!(
        report["plans"]
            .as_array()
            .expect("plans")
            .iter()
            .filter(|plan| plan["selector"] == "button_fast")
            .count(),
        1
    );
    fs::remove_dir_all(root).expect("cleanup");
}

/// `nichlink studio` honours its path argument, so the path Studio's own error
/// text tells the reader to pass actually reaches project resolution. Before
/// this, the subcommand dropped the argument and opened whatever directory the
/// process happened to be in.
/// `nichlink studio` 采纳它的路径参数,因此 Studio 自己的错误文本让读者传的路径确实
/// 到达项目解析。此前该子命令丢弃参数,打开的是进程恰好所在的那个目录。
#[test]
fn studio_honours_its_path_argument() {
    let missing = temporary_root("cli-studio-arg").join("no-such-project");
    let path = missing.display().to_string();
    let (result, _stdout) = run_capture(&["nichlink", "studio", &path]);
    let error = result.expect_err("an unusable path argument must be refused");
    assert!(
        error.contains("no-such-project"),
        "the refusal must name the path argument: {error}"
    );

    let (result, _stdout) = run_capture(&["nichlink", "studio", "one", "two"]);
    let error = result.expect_err("two paths must be refused");
    assert!(error.contains("at most one"), "{error}");

    let (result, stdout) = run_capture(&["nichlink", "studio", "--help"]);
    assert!(result.is_ok(), "{result:?}");
    assert!(
        stdout.contains("nichlink studio [path]"),
        "help and dispatch must agree on the argument: {stdout}"
    );
}

/// `check --json` emits one JSON document even when the package cannot be
/// resolved, so a machine reader is never handed an empty stdout.
/// 包解析不出来时 `check --json` 仍输出一个 JSON 文档,机器读者绝不会拿到空的 stdout。
#[test]
fn check_json_reports_a_resolution_failure_as_json() {
    let missing = temporary_root("cli-check-unresolvable").join("no-such-project");
    let path = missing.display().to_string();
    let (result, stdout) = run_capture(&["nichlink", "check", "--json", &path]);
    assert!(result.is_err(), "an unresolvable package must fail");
    let document: Value = serde_json::from_str(stdout.trim()).expect("one JSON document");
    assert_eq!(document["schema"], "nichlink.build-diagnostics/1");
    assert_eq!(document["count"], 1, "{document}");
    assert!(
        document["diagnostics"][0]["message"]
            .as_str()
            .unwrap_or_default()
            .contains("cannot resolve"),
        "{document}"
    );

    // Without `--json`, stdout stays reserved for the human line it has always
    // been; the error still names the failure on stderr.
    // 不带 `--json` 时 stdout 仍是它一直以来的那条人类可读行;错误仍在 stderr 上点名失败。
    let (result, stdout) = run_capture(&["nichlink", "check", &path]);
    assert!(result.is_err());
    assert!(stdout.is_empty(), "{stdout}");
}

/// A library target the manifest names but the filesystem does not have is
/// diagnosed through the same document, instead of taking the build down and
/// printing nothing.
/// 清单命名、而文件系统里没有的库目标经同一份文档被诊断,而不是打死构建并什么都不打印。
///
/// The manifest is legal — `[lib] path` is how Cargo is told where the library
/// is — so `cargo metadata` is asked about a package, and the pipeline reaches its
/// own layout resolution: the named file does not exist, which is a
/// `face-layout` diagnostic naming it. A *present* target outside `src/` is
/// supported now and builds (`cli::check_accepts_a_library_target_outside_src`).
/// It used to reach `expect("src directory must exist")` for either case: exit 101
/// and an empty stdout, which is exactly the contract `check --json` was fixed to
/// keep.
/// manifest 是合法的——`[lib] path` 正是告诉 Cargo 库在哪里的方式——因此包会被 `cargo metadata`
/// 询问,管线走到自己的布局解析:被命名的文件不存在,这就是一条点名它的 `face-layout` 诊断。
/// 位于 `src/` 之外但**存在**的目标现在被支持并能构建(`cli::check_accepts_a_library_target_outside_src`)。
/// 过去两种情形都会走到 `expect("src directory must exist")`:退出 101 与空 stdout,而那正是
/// `check --json` 被修好要守住的契约。
#[test]
fn check_reports_a_library_target_that_is_missing_as_a_diagnostic() {
    let root = temporary_root("cli-check-missing-lib");
    fs::write(
        root.join("Cargo.toml"),
        "[package]\nname = \"probe\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[lib]\nname = \"probe\"\npath = \"host/lib.rs\"\n",
    )
    .expect("manifest");
    let path = root.display().to_string();

    let (result, stdout) = run_capture(&["nichlink", "check", "--json", &path]);
    assert!(result.is_err(), "a package without src/ must fail");
    let document: Value = serde_json::from_str(stdout.trim()).expect("one JSON document");
    assert_eq!(document["count"], 1, "{document}");
    assert_eq!(
        document["diagnostics"][0]["phase"], "face-layout",
        "{document}"
    );
    assert!(
        document["diagnostics"][0]["message"]
            .as_str()
            .unwrap_or_default()
            .contains("host/lib.rs"),
        "the refusal must name the target it looked for: {document}"
    );

    // The human run keeps its shape: the failure is the returned error, and
    // stdout stays reserved for the document `--json` would have written.
    // 人类可读运行保持原样:失败由返回的错误给出,stdout 仍留给 `--json` 本会写出的文档。
    let (result, stdout) = run_capture(&["nichlink", "check", &path]);
    assert!(result.is_err());
    assert!(stdout.is_empty(), "{stdout}");

    let _ = fs::remove_dir_all(&root);
}

/// A library target outside `src/` is read where it is: the build succeeds on a
/// host whose faces live beside its library root, and `explain` reports the source
/// path the host's own build would stamp — `host/control/control.rs`, the
/// manifest-relative path, because the declaration macros drop only a leading
/// `src/`. This is the positive half of the test above: that one pins the refusal
/// for a target that is not there, this one pins the support for a target that is.
/// `src/` 之外的库目标就地读取:注册面住在库根旁边的宿主能构建成功,而 `explain` 报告的源码路径
/// 正是宿主自己的构建会盖下的那个——`host/control/control.rs`,相对清单的路径,因为声明宏只去掉
/// 一个前导 `src/`。这是上一条测试的正向一半:那条钉住"目标不在"时的拒绝,这条钉住"目标在"时的
/// 支持。
#[test]
fn check_accepts_a_library_target_outside_src() {
    let root = temporary_root("cli-outside-src");
    let face = root.join("host/control/control.rs");
    fs::create_dir_all(face.parent().expect("face parent")).expect("face directory");
    fs::write(
        &face,
        "crate::root_object! {\n    kind: Control,\n    needs_registry: true,\n    \
         parent: crate::root_node_id(env!(\"CARGO_PKG_NAME\")),\n}\n",
    )
    .expect("face");
    fs::write(root.join("host/lib.rs"), "// host entry\n").expect("library root");
    fs::write(
        root.join("Cargo.toml"),
        "[package]\nname = \"cli-outside-src\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[lib]\npath = \"host/lib.rs\"\n",
    )
    .expect("manifest");
    let path = root.display().to_string();

    let (checked, stdout) = run_capture(&["nichlink", "check", &path]);
    assert!(checked.is_ok(), "{checked:?} {stdout}");

    let (explained, stdout) = run_capture(&[
        "nichlink",
        "explain",
        "--json",
        "--path",
        &path,
        "root/control",
    ]);
    assert!(explained.is_ok(), "{explained:?} {stdout}");
    let document: Value = serde_json::from_str(stdout.trim()).expect("JSON report");
    assert_eq!(document["resolved"], true, "{document}");
    assert_eq!(
        document["node"]["source"], "host/control/control.rs",
        "the identity path keeps the directory the target lives in: {document}"
    );
    assert_eq!(document["node"]["path"], "root/control", "{document}");
    fs::remove_dir_all(root).expect("cleanup");
}

/// `grafts --json` emits one JSON document even when the package cannot be
/// resolved, and the document carries the failure.
/// 包解析不出来时 `grafts --json` 仍输出一个 JSON 文档,且文档携带该失败。
#[test]
fn grafts_json_reports_a_resolution_failure_as_json() {
    let missing = temporary_root("cli-grafts-unresolvable").join("no-such-project");
    let path = missing.display().to_string();
    let (result, stdout) = run_capture(&["nichlink", "grafts", "--json", &path]);
    assert!(result.is_err(), "an unresolvable package must fail");
    let document: Value = serde_json::from_str(stdout.trim()).expect("one JSON document");
    assert_eq!(document["schema"], "nichlink.grafts/1");
    assert_eq!(
        document["plans"].as_array().map(Vec::len),
        Some(0),
        "{document}"
    );
    assert!(
        document["error"]
            .as_str()
            .unwrap_or_default()
            .contains("cannot resolve"),
        "{document}"
    );
}

#[test]
fn help_and_missing_command_succeed() {
    assert!(run(["nichlink".to_owned(), "--help".to_owned()]).is_ok());
    assert!(run(["nichlink".to_owned()]).is_ok());
}

#[test]
fn unknown_command_is_an_error() {
    let result = run(["nichlink".to_owned(), "bogus".to_owned()]);
    assert!(result.is_err());
    assert!(result.unwrap_err().contains("unknown command 'bogus'"));
}

#[test]
fn new_requires_a_package_name() {
    let result = run(["nichlink".to_owned(), "new".to_owned()]);
    assert!(result.is_err());
    assert!(result.unwrap_err().contains("package name"));
}

#[test]
fn build_args_split_leading_path_from_cargo_options() {
    let owned = |items: &[&str]| {
        items
            .iter()
            .map(|item| item.to_string())
            .collect::<Vec<_>>()
    };
    let (path, rest) = split_build_args(&owned(&["app", "--release"]));
    assert_eq!(path.as_deref(), Some("app"));
    assert_eq!(rest, owned(&["--release"]));
    let (path, rest) = split_build_args(&owned(&["--release"]));
    assert_eq!(path, None);
    assert_eq!(rest, owned(&["--release"]));
    let (path, rest) = split_build_args(&[]);
    assert_eq!(path, None);
    assert!(rest.is_empty());
}

/// An unknown editor, or a path next to a config-dir editor, is refused
/// before anything is written.
/// 未知编辑器,或给"写配置目录"的编辑器附带路径,都在写盘之前拒绝。
#[test]
fn snippets_refuses_an_unknown_editor_and_a_stray_path() {
    let unknown = run([
        "nichlink".to_owned(),
        "snippets".to_owned(),
        "--editor".to_owned(),
        "emacs".to_owned(),
    ])
    .expect_err("unknown editor");
    assert!(unknown.contains("unknown editor 'emacs'"), "{unknown}");
    assert!(unknown.contains("vscode, nvim, blink"), "{unknown}");

    let stray = run([
        "nichlink".to_owned(),
        "snippets".to_owned(),
        "--editor".to_owned(),
        "nvim".to_owned(),
        "/tmp/somewhere".to_owned(),
    ])
    .expect_err("stray path");
    assert!(stray.contains("editor config"), "{stray}");
}

/// The command injects a parseable editor file covering the whole kernel
/// vocabulary, and refuses arguments it does not understand.
/// 该命令注入一个可解析、覆盖整个内核词表的编辑器文件,并拒绝看不懂的参数。
#[test]
fn snippets_injects_the_editor_file() {
    let root = std::env::temp_dir().join(format!(
        "nichlink-cli-snippets-{}-{}",
        std::process::id(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .expect("clock")
            .as_nanos()
    ));
    std::fs::create_dir_all(&root).expect("temporary directory");
    run([
        "nichlink".to_owned(),
        "snippets".to_owned(),
        root.display().to_string(),
    ])
    .expect("snippets command");

    let path = root.join(nichlink_build_method::scaffold::SNIPPET_FILE);
    let text = std::fs::read_to_string(&path).expect("editor file");
    let parsed: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
    let snippets = parsed.as_object().expect("an object of snippets");
    assert_eq!(
        snippets.len(),
        nichlink::registry_core::declaration::FACE_FIELD_ORDER.len()
    );
    let kind = snippets.get("kind: ").expect("the kind snippet");
    assert_eq!(kind["prefix"][0], "kind: ");
    assert_eq!(kind["body"][0], "kind: $1,");
    assert_eq!(kind["scope"], "rust");

    assert!(
        run([
            "nichlink".to_owned(),
            "snippets".to_owned(),
            "--bogus".to_owned()
        ])
        .is_err()
    );
    assert!(
        run([
            "nichlink".to_owned(),
            "snippets".to_owned(),
            "a".to_owned(),
            "b".to_owned()
        ])
        .is_err()
    );
    std::fs::remove_dir_all(root).expect("cleanup");
}

fn temporary_root(label: &str) -> PathBuf {
    let root = std::env::temp_dir().join(format!(
        "nichlink-{label}-{}-{}",
        std::process::id(),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .expect("clock")
            .as_nanos()
    ));
    std::fs::create_dir_all(&root).expect("temporary directory");
    root
}

/// A non-UTF-8 argument is refused by name instead of panicking.
/// 非 UTF-8 参数会被点名拒绝,而不是 panic。
#[cfg(unix)]
#[test]
fn a_non_utf8_argument_is_refused_by_name() {
    use std::ffi::OsString;
    use std::os::unix::ffi::OsStringExt;

    let bad = OsString::from_vec(b"/tmp/proj\xff".to_vec());
    let error = super::argv_strings([OsString::from("nichlink"), bad]).expect_err("refused");
    assert!(
        error.contains("is not valid UTF-8"),
        "the refusal names the reason: {error}"
    );
    // A valid argv still converts, so the check refuses bytes rather than the feature.
    // 合法 argv 照常转换,因此这道检查拒绝的是字节而不是功能。
    let argv = super::argv_strings([OsString::from("nichlink"), OsString::from("check")])
        .expect("valid arguments");
    assert_eq!(argv, ["nichlink", "check"]);
}