@nklisch/pi-agile-workflow 0.15.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +10 -0
- package/.codex-plugin/plugin.json +28 -0
- package/CHANGELOG.md +591 -0
- package/README.md +107 -0
- package/docs/ARCHITECTURE.md +716 -0
- package/docs/MIGRATION.md +419 -0
- package/docs/PRINCIPLES.md +464 -0
- package/docs/ROADMAP.md +95 -0
- package/docs/SPEC.md +855 -0
- package/docs/VISION.md +106 -0
- package/extensions/agile-workflow.test.ts +419 -0
- package/extensions/agile-workflow.ts +467 -0
- package/hooks/hooks.json +52 -0
- package/hooks/scripts/prompt-context.py +628 -0
- package/hooks/scripts/substrate-maintainer.py +359 -0
- package/hooks/scripts/test_prompt_context.py +757 -0
- package/package.json +30 -0
- package/scripts/install-work-view.sh +165 -0
- package/scripts/tests/agent-metadata.test.sh +75 -0
- package/scripts/tests/bump-version.test.sh +552 -0
- package/scripts/tests/channel-parity.test.sh +117 -0
- package/scripts/tests/convert-content-integrity.test.sh +246 -0
- package/scripts/tests/convert-install-routing.test.sh +159 -0
- package/scripts/tests/install-work-view.test.sh +411 -0
- package/scripts/tests/pi-package-metadata.test.sh +119 -0
- package/scripts/tests/work-board-shim.test.sh +239 -0
- package/scripts/tests/work-view-dist-version.test.sh +105 -0
- package/scripts/work-board.sh +64 -0
- package/scripts/work-view.sh +423 -0
- package/skills/autopilot/SKILL.md +330 -0
- package/skills/autopilot/agents/openai.yaml +6 -0
- package/skills/board/SKILL.md +62 -0
- package/skills/board/agents/openai.yaml +6 -0
- package/skills/bold-refactor/SKILL.md +244 -0
- package/skills/bold-refactor/agents/openai.yaml +6 -0
- package/skills/bug-scan/SKILL.md +378 -0
- package/skills/bug-scan/agents/openai.yaml +6 -0
- package/skills/bug-scan/references/async-promises.md +119 -0
- package/skills/bug-scan/references/concurrency-races.md +148 -0
- package/skills/bug-scan/references/data-layer.md +167 -0
- package/skills/bug-scan/references/error-handling.md +197 -0
- package/skills/bug-scan/references/gate-item-template.md +94 -0
- package/skills/bug-scan/references/language-footguns.md +219 -0
- package/skills/bug-scan/references/parked-item-template.md +103 -0
- package/skills/bug-scan/references/report-template.md +115 -0
- package/skills/bug-scan/references/resource-leaks.md +199 -0
- package/skills/bug-scan/references/state-closures.md +148 -0
- package/skills/bug-scan/references/time-numbers.md +158 -0
- package/skills/convert/SKILL.md +1463 -0
- package/skills/convert/agents/openai.yaml +6 -0
- package/skills/convert/references/legacy-overlap-migration.md +179 -0
- package/skills/deep-code-scan/SKILL.md +378 -0
- package/skills/deep-code-scan/agents/openai.yaml +6 -0
- package/skills/deep-code-scan/references/artifact-ledger.md +148 -0
- package/skills/deep-code-scan/references/consolidation.md +91 -0
- package/skills/deep-code-scan/references/decomposition.md +127 -0
- package/skills/deep-code-scan/references/item-templates.md +263 -0
- package/skills/deep-code-scan/references/lane-catalog.md +129 -0
- package/skills/deep-code-scan/references/review-gauntlet.md +108 -0
- package/skills/deep-code-scan/references/scanner-brief.md +152 -0
- package/skills/e2e-test-design/SKILL.md +517 -0
- package/skills/e2e-test-design/references/anti-tautology.md +261 -0
- package/skills/e2e-test-design/references/service-mocks.md +234 -0
- package/skills/e2e-test-design/references/test-taxonomy.md +211 -0
- package/skills/epic-design/SKILL.md +476 -0
- package/skills/epicize/SKILL.md +210 -0
- package/skills/epicize/agents/openai.yaml +6 -0
- package/skills/feature-design/SKILL.md +472 -0
- package/skills/fix/SKILL.md +173 -0
- package/skills/gate-cruft/SKILL.md +258 -0
- package/skills/gate-docs/SKILL.md +278 -0
- package/skills/gate-patterns/SKILL.md +389 -0
- package/skills/gate-refactor/SKILL.md +408 -0
- package/skills/gate-security/SKILL.md +275 -0
- package/skills/gate-tests/SKILL.md +367 -0
- package/skills/groom/SKILL.md +162 -0
- package/skills/groom/agents/openai.yaml +6 -0
- package/skills/ideate/SKILL.md +185 -0
- package/skills/ideate/agents/openai.yaml +6 -0
- package/skills/implement/SKILL.md +288 -0
- package/skills/implement-orchestrator/SKILL.md +604 -0
- package/skills/park/SKILL.md +111 -0
- package/skills/perf-design/SKILL.md +427 -0
- package/skills/perf-scout/SKILL.md +404 -0
- package/skills/perf-scout/agents/openai.yaml +6 -0
- package/skills/perf-scout/references/algorithmic-and-data-structures.md +126 -0
- package/skills/perf-scout/references/approximation-and-precomputation.md +136 -0
- package/skills/perf-scout/references/caching-and-memoization.md +123 -0
- package/skills/perf-scout/references/compiler-runtime-and-language.md +136 -0
- package/skills/perf-scout/references/database-and-storage-internals.md +144 -0
- package/skills/perf-scout/references/distributed-systems.md +150 -0
- package/skills/perf-scout/references/game-engine-and-realtime.md +136 -0
- package/skills/perf-scout/references/gpu-and-accelerators.md +130 -0
- package/skills/perf-scout/references/idea-ranking.md +75 -0
- package/skills/perf-scout/references/io-and-batching.md +135 -0
- package/skills/perf-scout/references/memory-and-data-locality.md +137 -0
- package/skills/perf-scout/references/parallelism-and-vectorization.md +126 -0
- package/skills/perf-scout/references/parked-item-template.md +117 -0
- package/skills/perf-scout/references/peer-review-pass.md +150 -0
- package/skills/perf-scout/references/report-template.md +148 -0
- package/skills/principles/SKILL.md +678 -0
- package/skills/principles/references/models.md +191 -0
- package/skills/principles/references/subagents.md +158 -0
- package/skills/prose-author/SKILL.md +185 -0
- package/skills/refactor-conventions-creator/SKILL.md +359 -0
- package/skills/refactor-conventions-creator/agents/openai.yaml +6 -0
- package/skills/refactor-conventions-creator/references/common-structures.md +91 -0
- package/skills/refactor-conventions-creator/references/common-styles.md +90 -0
- package/skills/refactor-design/SKILL.md +384 -0
- package/skills/release-deploy/SKILL.md +667 -0
- package/skills/release-deploy/agents/openai.yaml +6 -0
- package/skills/research/SKILL.md +166 -0
- package/skills/research/agents/openai.yaml +6 -0
- package/skills/review/SKILL.md +267 -0
- package/skills/review/references/deep-review.md +98 -0
- package/skills/review/references/review-lenses.md +68 -0
- package/skills/review/references/substrate-side-effects.md +161 -0
- package/skills/review/references/target-resolution.md +52 -0
- package/skills/scope/SKILL.md +486 -0
- package/work-view/Cargo.lock +486 -0
- package/work-view/Cargo.toml +9 -0
- package/work-view/crates/cli/.work-view-version +1 -0
- package/work-view/crates/cli/Cargo.toml +18 -0
- package/work-view/crates/cli/src/actionable.rs +500 -0
- package/work-view/crates/cli/src/args.rs +792 -0
- package/work-view/crates/cli/src/board/assets/board.css +855 -0
- package/work-view/crates/cli/src/board/assets/board.js +259 -0
- package/work-view/crates/cli/src/board/assets/card.js +139 -0
- package/work-view/crates/cli/src/board/assets/components.css +290 -0
- package/work-view/crates/cli/src/board/assets/dependency.js +1193 -0
- package/work-view/crates/cli/src/board/assets/detail.js +256 -0
- package/work-view/crates/cli/src/board/assets/filters.js +389 -0
- package/work-view/crates/cli/src/board/assets/index.html +77 -0
- package/work-view/crates/cli/src/board/assets/kanban.js +231 -0
- package/work-view/crates/cli/src/board/assets/markdown.js +260 -0
- package/work-view/crates/cli/src/board/assets/motion.css +131 -0
- package/work-view/crates/cli/src/board/assets/state.js +245 -0
- package/work-view/crates/cli/src/board/assets/table.js +304 -0
- package/work-view/crates/cli/src/board/assets/tokens.css +602 -0
- package/work-view/crates/cli/src/board/assets/views.js +41 -0
- package/work-view/crates/cli/src/board/assets.rs +162 -0
- package/work-view/crates/cli/src/board/feed.rs +204 -0
- package/work-view/crates/cli/src/board/mod.rs +246 -0
- package/work-view/crates/cli/src/board/open.rs +144 -0
- package/work-view/crates/cli/src/board/server.rs +407 -0
- package/work-view/crates/cli/src/main.rs +195 -0
- package/work-view/crates/cli/src/render.rs +354 -0
- package/work-view/crates/cli/src/scope.rs +157 -0
- package/work-view/crates/cli/src/stale.rs +581 -0
- package/work-view/crates/cli/tests/board-js/dependency-table.test.mjs +64 -0
- package/work-view/crates/cli/tests/board-js/expanded-browsing.test.mjs +79 -0
- package/work-view/crates/cli/tests/board-js/harness-smoke.test.mjs +11 -0
- package/work-view/crates/cli/tests/board-js/harness.mjs +437 -0
- package/work-view/crates/cli/tests/board-js/kanban-detail.test.mjs +75 -0
- package/work-view/crates/cli/tests/board-js/markdown-filter.test.mjs +60 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/CONVENTIONS.md +3 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/epics/epic-alpha.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-a.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/features/feat-b.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-alpha-1.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/active/stories/story-research-1.md +19 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-done.md +16 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/archive/feat-shipped.md +18 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/backlog/idea-backlog.md +7 -0
- package/work-view/crates/cli/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/CONVENTIONS.md +6 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-dep-done.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-design-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/features/feat-impl-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/ready-drafting/.work/active/stories/story-review-ready.md +17 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/CONVENTIONS.md +4 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/epics/malformed-no-closing-fence.md +8 -0
- package/work-view/crates/cli/tests/fixtures/with-malformed/.work/active/features/good-item.md +16 -0
- package/work-view/crates/cli/tests/integration.rs +2833 -0
- package/work-view/crates/core/Cargo.toml +13 -0
- package/work-view/crates/core/src/error.rs +61 -0
- package/work-view/crates/core/src/filter.rs +804 -0
- package/work-view/crates/core/src/graph.rs +324 -0
- package/work-view/crates/core/src/index.rs +590 -0
- package/work-view/crates/core/src/lib.rs +54 -0
- package/work-view/crates/core/src/model.rs +202 -0
- package/work-view/crates/core/src/parse.rs +494 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/CONVENTIONS.md +15 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/epics/epic-core.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-parser.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/features/feat-query.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/malformed-no-id.md +15 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-parse-unit.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/active/stories/story-security-gate.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-old.md +16 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/archive/feat-parser.md +17 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/backlog/idea-future-board.md +7 -0
- package/work-view/crates/core/tests/fixtures/golden/.work/releases/v1.0/v1.0.md +16 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/CONVENTIONS.md +10 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/archive/dup-item.md +18 -0
- package/work-view/crates/core/tests/fixtures/precedence/.work/releases/v2.0/dup-item.md +18 -0
- package/work-view/crates/core/tests/integration.rs +541 -0
- package/work-view/dist/.gitattributes +1 -0
- package/work-view/dist/README.md +42 -0
- package/work-view/dist/aarch64-apple-darwin/work-view +0 -0
- package/work-view/dist/aarch64-unknown-linux-musl/work-view +0 -0
- package/work-view/dist/x86_64-apple-darwin/work-view +0 -0
- package/work-view/dist/x86_64-unknown-linux-musl/work-view +0 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
//! Domain model for a single substrate item.
|
|
2
|
+
//!
|
|
3
|
+
//! `Item` is the central type in this crate. Every other module either
|
|
4
|
+
//! produces `Item`s (parse, index) or operates on them (graph, filter).
|
|
5
|
+
|
|
6
|
+
use std::path::PathBuf;
|
|
7
|
+
|
|
8
|
+
/// The substrate directory tier an item was loaded from.
|
|
9
|
+
///
|
|
10
|
+
/// Derived from the load directory, NOT from frontmatter. Immutable once set.
|
|
11
|
+
///
|
|
12
|
+
/// Used by:
|
|
13
|
+
/// - `Item::is_terminal` (Releases | Archive → terminal regardless of stage)
|
|
14
|
+
/// - Tier-aware validation (active/releases items require full frontmatter)
|
|
15
|
+
/// - Duplicate-id precedence resolution (active > releases > archive > backlog)
|
|
16
|
+
/// - Board bucketing (`tier` maps directly to the board's bucket field)
|
|
17
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
18
|
+
pub enum Tier {
|
|
19
|
+
/// `.work/active/**` — in-flight work (epics, features, stories)
|
|
20
|
+
Active,
|
|
21
|
+
/// `.work/releases/**` — release items; terminal by tier
|
|
22
|
+
Releases,
|
|
23
|
+
/// `.work/archive/**` — completed/abandoned items; terminal by tier
|
|
24
|
+
Archive,
|
|
25
|
+
/// `.work/backlog/**` — leaner items (only `id`/`created`/`tags` required)
|
|
26
|
+
Backlog,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
impl Tier {
|
|
30
|
+
/// Precedence value for `by_id` duplicate resolution.
|
|
31
|
+
/// Lower is higher precedence: Active=0, Releases=1, Archive=2, Backlog=3.
|
|
32
|
+
pub fn precedence(self) -> u8 {
|
|
33
|
+
match self {
|
|
34
|
+
Tier::Active => 0,
|
|
35
|
+
Tier::Releases => 1,
|
|
36
|
+
Tier::Archive => 2,
|
|
37
|
+
Tier::Backlog => 3,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// A single parsed item from the substrate.
|
|
43
|
+
///
|
|
44
|
+
/// All optional scalar fields are normalized: missing / YAML `null` / literal
|
|
45
|
+
/// `"null"` string / empty string → `None`. This applies to `kind`, `stage`,
|
|
46
|
+
/// `parent`, `release_binding`, `gate_origin`, `research_origin`, and
|
|
47
|
+
/// `scan_origin`.
|
|
48
|
+
///
|
|
49
|
+
/// `tags`, `depends_on`, and `research_refs` are always a `Vec<String>`;
|
|
50
|
+
/// missing/empty frontmatter yields an empty vec (never `None`).
|
|
51
|
+
#[derive(Debug, Clone)]
|
|
52
|
+
pub struct Item {
|
|
53
|
+
/// Item identifier. Unique within the active tier; may repeat across tiers.
|
|
54
|
+
pub id: String,
|
|
55
|
+
|
|
56
|
+
/// Item kind (`epic`, `feature`, `story`, `release`).
|
|
57
|
+
/// `None` for backlog items (not yet scoped) or if missing from frontmatter.
|
|
58
|
+
pub kind: Option<String>,
|
|
59
|
+
|
|
60
|
+
/// Workflow stage (e.g. `drafting`, `implementing`, `review`, `done`).
|
|
61
|
+
/// `None` for backlog items or if missing from frontmatter.
|
|
62
|
+
pub stage: Option<String>,
|
|
63
|
+
|
|
64
|
+
/// Routing/classification tags (AND semantics in filters).
|
|
65
|
+
pub tags: Vec<String>,
|
|
66
|
+
|
|
67
|
+
/// Parent item id, if any.
|
|
68
|
+
pub parent: Option<String>,
|
|
69
|
+
|
|
70
|
+
/// Ids of items this item depends on (must be terminal before this starts).
|
|
71
|
+
pub depends_on: Vec<String>,
|
|
72
|
+
|
|
73
|
+
/// Release version this item is bound to (late-binding).
|
|
74
|
+
pub release_binding: Option<String>,
|
|
75
|
+
|
|
76
|
+
/// Gate that produced this item (`security`, `tests`, etc.), if any.
|
|
77
|
+
pub gate_origin: Option<String>,
|
|
78
|
+
|
|
79
|
+
/// Research artifact(s) this work item tracks/consumes (Arrow 1, coordination).
|
|
80
|
+
pub research_refs: Vec<String>,
|
|
81
|
+
/// Research artifact that spawned this work item (Arrow 2, grounding).
|
|
82
|
+
pub research_origin: Option<String>,
|
|
83
|
+
|
|
84
|
+
/// Scan artifact that originated/spawned this work item (mirror of research_origin).
|
|
85
|
+
pub scan_origin: Option<String>,
|
|
86
|
+
|
|
87
|
+
/// ISO date when this item was created (`YYYY-MM-DD`).
|
|
88
|
+
pub created: Option<String>,
|
|
89
|
+
|
|
90
|
+
/// ISO date when this item was last updated (`YYYY-MM-DD`).
|
|
91
|
+
pub updated: Option<String>,
|
|
92
|
+
|
|
93
|
+
/// Substrate directory tier (derived from the load directory).
|
|
94
|
+
pub tier: Tier,
|
|
95
|
+
|
|
96
|
+
/// Absolute path to the item file on disk.
|
|
97
|
+
pub path: PathBuf,
|
|
98
|
+
|
|
99
|
+
/// Path relative to the substrate root (for board rendering and compact display).
|
|
100
|
+
pub rel_path: PathBuf,
|
|
101
|
+
|
|
102
|
+
/// Full raw file contents including the frontmatter block.
|
|
103
|
+
///
|
|
104
|
+
/// Used by the CLI adapter's `--cat` mode (matches `cat "$f"` exactly).
|
|
105
|
+
pub raw_text: String,
|
|
106
|
+
|
|
107
|
+
/// Markdown body text AFTER the closing frontmatter `---` delimiter.
|
|
108
|
+
///
|
|
109
|
+
/// Used by board rendering. Does NOT include the frontmatter block.
|
|
110
|
+
pub body: String,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
impl Item {
|
|
114
|
+
/// Returns `true` if this item is considered "terminal" (done/released).
|
|
115
|
+
///
|
|
116
|
+
/// Terminal conditions:
|
|
117
|
+
/// - Tier is `Releases` or `Archive` (irrespective of stage), OR
|
|
118
|
+
/// - `stage` is `"done"` or `"released"`.
|
|
119
|
+
///
|
|
120
|
+
/// Path string-matching is intentionally NOT used — `tier` is the
|
|
121
|
+
/// authoritative signal.
|
|
122
|
+
pub fn is_terminal(&self) -> bool {
|
|
123
|
+
match self.tier {
|
|
124
|
+
Tier::Releases | Tier::Archive => true,
|
|
125
|
+
Tier::Active | Tier::Backlog => {
|
|
126
|
+
matches!(self.stage.as_deref(), Some("done") | Some("released"))
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
#[cfg(test)]
|
|
133
|
+
mod tests {
|
|
134
|
+
use super::*;
|
|
135
|
+
use std::path::PathBuf;
|
|
136
|
+
|
|
137
|
+
fn make_item(tier: Tier, stage: Option<&str>) -> Item {
|
|
138
|
+
Item {
|
|
139
|
+
id: "test-id".into(),
|
|
140
|
+
kind: None,
|
|
141
|
+
stage: stage.map(str::to_owned),
|
|
142
|
+
tags: vec![],
|
|
143
|
+
parent: None,
|
|
144
|
+
depends_on: vec![],
|
|
145
|
+
release_binding: None,
|
|
146
|
+
gate_origin: None,
|
|
147
|
+
research_refs: vec![],
|
|
148
|
+
research_origin: None,
|
|
149
|
+
scan_origin: None,
|
|
150
|
+
created: None,
|
|
151
|
+
updated: None,
|
|
152
|
+
tier,
|
|
153
|
+
path: PathBuf::from("/fake/path.md"),
|
|
154
|
+
rel_path: PathBuf::from(".work/active/stories/test-id.md"),
|
|
155
|
+
raw_text: String::new(),
|
|
156
|
+
body: String::new(),
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#[test]
|
|
161
|
+
fn is_terminal_releases_tier_always_terminal() {
|
|
162
|
+
assert!(make_item(Tier::Releases, None).is_terminal());
|
|
163
|
+
assert!(make_item(Tier::Releases, Some("planned")).is_terminal());
|
|
164
|
+
assert!(make_item(Tier::Releases, Some("released")).is_terminal());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#[test]
|
|
168
|
+
fn is_terminal_archive_tier_always_terminal() {
|
|
169
|
+
assert!(make_item(Tier::Archive, None).is_terminal());
|
|
170
|
+
assert!(make_item(Tier::Archive, Some("implementing")).is_terminal());
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#[test]
|
|
174
|
+
fn is_terminal_active_done_stage() {
|
|
175
|
+
assert!(make_item(Tier::Active, Some("done")).is_terminal());
|
|
176
|
+
assert!(make_item(Tier::Active, Some("released")).is_terminal());
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#[test]
|
|
180
|
+
fn is_terminal_active_non_done_not_terminal() {
|
|
181
|
+
assert!(!make_item(Tier::Active, Some("implementing")).is_terminal());
|
|
182
|
+
assert!(!make_item(Tier::Active, Some("review")).is_terminal());
|
|
183
|
+
assert!(!make_item(Tier::Active, Some("drafting")).is_terminal());
|
|
184
|
+
assert!(!make_item(Tier::Active, None).is_terminal());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#[test]
|
|
188
|
+
fn is_terminal_backlog_tier_non_terminal_by_default() {
|
|
189
|
+
// Backlog items are not terminal unless their stage is done/released
|
|
190
|
+
// (which is unusual, but the logic is consistent)
|
|
191
|
+
assert!(!make_item(Tier::Backlog, None).is_terminal());
|
|
192
|
+
assert!(!make_item(Tier::Backlog, Some("implementing")).is_terminal());
|
|
193
|
+
assert!(make_item(Tier::Backlog, Some("done")).is_terminal());
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
#[test]
|
|
197
|
+
fn tier_precedence_order() {
|
|
198
|
+
assert!(Tier::Active.precedence() < Tier::Releases.precedence());
|
|
199
|
+
assert!(Tier::Releases.precedence() < Tier::Archive.precedence());
|
|
200
|
+
assert!(Tier::Archive.precedence() < Tier::Backlog.precedence());
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
//! Frontmatter parsing: split `---`/`---` block, deserialize YAML, build `Item`.
|
|
2
|
+
//!
|
|
3
|
+
//! Key invariants:
|
|
4
|
+
//! - Only `id` is required; all other frontmatter fields are optional.
|
|
5
|
+
//! - Optional scalars are normalized: missing / YAML null / `"null"` / `""` → `None`.
|
|
6
|
+
//! - Arrays (`tags`, `depends_on`) handle both flow style (`[a, b]`) and block
|
|
7
|
+
//! style (`- a\n - b`) natively via serde.
|
|
8
|
+
//! - `raw_text` is the FULL file content (for `--cat` parity with `cat "$f"`).
|
|
9
|
+
//! - `body` is the markdown AFTER the closing `---` (for board rendering).
|
|
10
|
+
//! - Missing `id` or invalid YAML → `ParseError` (non-fatal).
|
|
11
|
+
|
|
12
|
+
use std::path::Path;
|
|
13
|
+
|
|
14
|
+
use serde::Deserialize;
|
|
15
|
+
|
|
16
|
+
use crate::{
|
|
17
|
+
error::ParseError,
|
|
18
|
+
model::{Item, Tier},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/// Raw deserialization target. Only `id` is required; everything else has a
|
|
22
|
+
/// serde default so backlog items (which omit `kind`/`stage`) parse cleanly.
|
|
23
|
+
#[derive(Debug, Deserialize)]
|
|
24
|
+
struct RawFrontmatter {
|
|
25
|
+
id: String,
|
|
26
|
+
#[serde(default)]
|
|
27
|
+
kind: Option<String>,
|
|
28
|
+
#[serde(default)]
|
|
29
|
+
stage: Option<String>,
|
|
30
|
+
#[serde(default)]
|
|
31
|
+
tags: Vec<String>,
|
|
32
|
+
#[serde(default)]
|
|
33
|
+
parent: Option<String>,
|
|
34
|
+
#[serde(default)]
|
|
35
|
+
depends_on: Vec<String>,
|
|
36
|
+
#[serde(default)]
|
|
37
|
+
release_binding: Option<String>,
|
|
38
|
+
#[serde(default)]
|
|
39
|
+
gate_origin: Option<String>,
|
|
40
|
+
#[serde(default)]
|
|
41
|
+
research_refs: Vec<String>,
|
|
42
|
+
#[serde(default)]
|
|
43
|
+
research_origin: Option<String>,
|
|
44
|
+
#[serde(default)]
|
|
45
|
+
scan_origin: Option<String>,
|
|
46
|
+
#[serde(default)]
|
|
47
|
+
created: Option<String>,
|
|
48
|
+
#[serde(default)]
|
|
49
|
+
updated: Option<String>,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Normalize an optional scalar: missing / YAML null / literal `"null"` /
|
|
53
|
+
/// empty-or-whitespace → `None`.
|
|
54
|
+
fn normalize_optional(v: Option<String>) -> Option<String> {
|
|
55
|
+
match v {
|
|
56
|
+
None => None,
|
|
57
|
+
Some(s) => {
|
|
58
|
+
let trimmed = s.trim();
|
|
59
|
+
if trimmed.is_empty() || trimmed == "null" {
|
|
60
|
+
None
|
|
61
|
+
} else {
|
|
62
|
+
Some(trimmed.to_owned())
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// Normalize a `Vec<String>` by trimming each element and removing any
|
|
69
|
+
/// entry that normalizes to empty/null (defensive, in case YAML gives us
|
|
70
|
+
/// odd entries).
|
|
71
|
+
fn normalize_vec(v: Vec<String>) -> Vec<String> {
|
|
72
|
+
v.into_iter()
|
|
73
|
+
.filter_map(|s| {
|
|
74
|
+
let t = s.trim().to_owned();
|
|
75
|
+
if t.is_empty() || t == "null" {
|
|
76
|
+
None
|
|
77
|
+
} else {
|
|
78
|
+
Some(t)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
.collect()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Split a file's text into (frontmatter_yaml, body_after_frontmatter).
|
|
85
|
+
///
|
|
86
|
+
/// Returns `None` if no valid `---`/`---` frontmatter block is found.
|
|
87
|
+
/// `body` is everything after the closing `---\n` (or `---` at EOF).
|
|
88
|
+
fn split_frontmatter(text: &str) -> Option<(&str, &str)> {
|
|
89
|
+
// The file must start with `---` (optionally followed by spaces/nothing)
|
|
90
|
+
let after_open = text.strip_prefix("---")?;
|
|
91
|
+
// After the opening `---` there must be a newline (or just `---\n`)
|
|
92
|
+
let after_open = if let Some(rest) = after_open.strip_prefix('\n') {
|
|
93
|
+
rest
|
|
94
|
+
} else if let Some(rest) = after_open.strip_prefix("\r\n") {
|
|
95
|
+
rest
|
|
96
|
+
} else {
|
|
97
|
+
// `---` must be alone on the first line
|
|
98
|
+
return None;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Find the closing `---` on its own line
|
|
102
|
+
// We search for `\n---` to anchor to a line start.
|
|
103
|
+
let close_marker = "\n---";
|
|
104
|
+
let close_pos = after_open.find(close_marker)?;
|
|
105
|
+
let fm_yaml = &after_open[..close_pos];
|
|
106
|
+
|
|
107
|
+
// Body is everything after the closing `---` line (skip optional trailing
|
|
108
|
+
// whitespace on that line and the newline that follows).
|
|
109
|
+
let rest = &after_open[close_pos + close_marker.len()..];
|
|
110
|
+
let body = if let Some(stripped) = rest.strip_prefix('\n') {
|
|
111
|
+
stripped
|
|
112
|
+
} else if let Some(stripped) = rest.strip_prefix("\r\n") {
|
|
113
|
+
stripped
|
|
114
|
+
} else if rest.starts_with([' ', '\t', '\r']) {
|
|
115
|
+
// `--- \n` — skip trailing spaces then newline
|
|
116
|
+
let trimmed = rest.trim_start_matches([' ', '\t', '\r']);
|
|
117
|
+
if let Some(stripped) = trimmed.strip_prefix('\n') {
|
|
118
|
+
stripped
|
|
119
|
+
} else {
|
|
120
|
+
trimmed
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
rest
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
Some((fm_yaml, body))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// Parse a single `.work/` item file.
|
|
130
|
+
///
|
|
131
|
+
/// # Arguments
|
|
132
|
+
/// - `path` — absolute path to the file (stored on the `Item`)
|
|
133
|
+
/// - `rel` — path relative to the substrate root (stored on the `Item`)
|
|
134
|
+
/// - `tier` — which tier directory this file came from
|
|
135
|
+
/// - `text` — full raw file contents (stored as `raw_text`)
|
|
136
|
+
///
|
|
137
|
+
/// # Errors
|
|
138
|
+
/// Returns `ParseError` if:
|
|
139
|
+
/// - The file contains no valid frontmatter block.
|
|
140
|
+
/// - The frontmatter YAML is syntactically invalid.
|
|
141
|
+
/// - The frontmatter lacks an `id` field.
|
|
142
|
+
pub fn parse_item(path: &Path, rel: &Path, tier: Tier, text: &str) -> Result<Item, ParseError> {
|
|
143
|
+
let (fm_yaml, body) = split_frontmatter(text).ok_or_else(|| ParseError {
|
|
144
|
+
path: path.to_owned(),
|
|
145
|
+
reason: "no valid frontmatter block (expected `---` / `---` delimiters)".into(),
|
|
146
|
+
})?;
|
|
147
|
+
|
|
148
|
+
let raw: RawFrontmatter = serde_yaml_ng::from_str(fm_yaml).map_err(|e| ParseError {
|
|
149
|
+
path: path.to_owned(),
|
|
150
|
+
reason: format!("invalid YAML frontmatter: {e}"),
|
|
151
|
+
})?;
|
|
152
|
+
|
|
153
|
+
// Validate id is non-empty after trimming
|
|
154
|
+
let id = raw.id.trim().to_owned();
|
|
155
|
+
if id.is_empty() {
|
|
156
|
+
return Err(ParseError {
|
|
157
|
+
path: path.to_owned(),
|
|
158
|
+
reason: "frontmatter `id` field is empty".into(),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
Ok(Item {
|
|
163
|
+
id,
|
|
164
|
+
kind: normalize_optional(raw.kind),
|
|
165
|
+
stage: normalize_optional(raw.stage),
|
|
166
|
+
tags: normalize_vec(raw.tags),
|
|
167
|
+
parent: normalize_optional(raw.parent),
|
|
168
|
+
depends_on: normalize_vec(raw.depends_on),
|
|
169
|
+
release_binding: normalize_optional(raw.release_binding),
|
|
170
|
+
gate_origin: normalize_optional(raw.gate_origin),
|
|
171
|
+
research_refs: normalize_vec(raw.research_refs),
|
|
172
|
+
research_origin: normalize_optional(raw.research_origin),
|
|
173
|
+
scan_origin: normalize_optional(raw.scan_origin),
|
|
174
|
+
created: normalize_optional(raw.created),
|
|
175
|
+
updated: normalize_optional(raw.updated),
|
|
176
|
+
tier,
|
|
177
|
+
path: path.to_owned(),
|
|
178
|
+
rel_path: rel.to_owned(),
|
|
179
|
+
raw_text: text.to_owned(),
|
|
180
|
+
body: body.to_owned(),
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#[cfg(test)]
|
|
185
|
+
mod tests {
|
|
186
|
+
use super::*;
|
|
187
|
+
use std::path::PathBuf;
|
|
188
|
+
|
|
189
|
+
fn p(s: &str) -> PathBuf {
|
|
190
|
+
PathBuf::from(s)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/// Helper: parse with Active tier and dummy paths.
|
|
194
|
+
fn parse(text: &str) -> Result<Item, ParseError> {
|
|
195
|
+
parse_item(
|
|
196
|
+
&p("/repo/.work/active/features/test.md"),
|
|
197
|
+
&p(".work/active/features/test.md"),
|
|
198
|
+
Tier::Active,
|
|
199
|
+
text,
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#[test]
|
|
204
|
+
fn full_item_parses_correctly() {
|
|
205
|
+
let text = "\
|
|
206
|
+
---
|
|
207
|
+
id: my-feature
|
|
208
|
+
kind: feature
|
|
209
|
+
stage: implementing
|
|
210
|
+
tags: [tooling, perf]
|
|
211
|
+
parent: my-epic
|
|
212
|
+
depends_on: [dep-a, dep-b]
|
|
213
|
+
release_binding: v1.0.0
|
|
214
|
+
gate_origin: null
|
|
215
|
+
created: 2026-01-01
|
|
216
|
+
updated: 2026-05-30
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
# My Feature
|
|
220
|
+
|
|
221
|
+
Some body text.
|
|
222
|
+
";
|
|
223
|
+
let item = parse(text).unwrap();
|
|
224
|
+
assert_eq!(item.id, "my-feature");
|
|
225
|
+
assert_eq!(item.kind.as_deref(), Some("feature"));
|
|
226
|
+
assert_eq!(item.stage.as_deref(), Some("implementing"));
|
|
227
|
+
assert_eq!(item.tags, vec!["tooling", "perf"]);
|
|
228
|
+
assert_eq!(item.parent.as_deref(), Some("my-epic"));
|
|
229
|
+
assert_eq!(item.depends_on, vec!["dep-a", "dep-b"]);
|
|
230
|
+
assert_eq!(item.release_binding.as_deref(), Some("v1.0.0"));
|
|
231
|
+
assert!(item.gate_origin.is_none());
|
|
232
|
+
assert!(item.research_refs.is_empty());
|
|
233
|
+
assert!(item.research_origin.is_none());
|
|
234
|
+
assert_eq!(item.created.as_deref(), Some("2026-01-01"));
|
|
235
|
+
assert_eq!(item.updated.as_deref(), Some("2026-05-30"));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#[test]
|
|
239
|
+
fn raw_text_is_full_file_body_excludes_frontmatter() {
|
|
240
|
+
let text = "\
|
|
241
|
+
---
|
|
242
|
+
id: abc
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
# Title
|
|
246
|
+
|
|
247
|
+
Body paragraph.
|
|
248
|
+
";
|
|
249
|
+
let item = parse(text).unwrap();
|
|
250
|
+
// raw_text is the full file
|
|
251
|
+
assert_eq!(item.raw_text, text);
|
|
252
|
+
// body is after the closing `---`
|
|
253
|
+
assert!(item.body.contains("# Title"));
|
|
254
|
+
assert!(item.body.contains("Body paragraph."));
|
|
255
|
+
// body must NOT contain the frontmatter
|
|
256
|
+
assert!(!item.body.contains("id: abc"));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#[test]
|
|
260
|
+
fn backlog_minimal_item_parses_with_none_kind_stage() {
|
|
261
|
+
let text = "\
|
|
262
|
+
---
|
|
263
|
+
id: my-backlog-idea
|
|
264
|
+
created: 2026-01-15
|
|
265
|
+
tags: [tooling]
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
An unscoped idea capture.
|
|
269
|
+
";
|
|
270
|
+
let item = parse_item(
|
|
271
|
+
&p("/repo/.work/backlog/my-backlog-idea.md"),
|
|
272
|
+
&p(".work/backlog/my-backlog-idea.md"),
|
|
273
|
+
Tier::Backlog,
|
|
274
|
+
text,
|
|
275
|
+
)
|
|
276
|
+
.unwrap();
|
|
277
|
+
assert_eq!(item.id, "my-backlog-idea");
|
|
278
|
+
assert!(item.kind.is_none(), "backlog item should have None kind");
|
|
279
|
+
assert!(item.stage.is_none(), "backlog item should have None stage");
|
|
280
|
+
assert_eq!(item.tags, vec!["tooling"]);
|
|
281
|
+
assert!(item.depends_on.is_empty());
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
#[test]
|
|
285
|
+
fn flow_array_tags_parse() {
|
|
286
|
+
let text = "---\nid: x\ntags: [alpha, beta, gamma]\n---\n";
|
|
287
|
+
let item = parse(text).unwrap();
|
|
288
|
+
assert_eq!(item.tags, vec!["alpha", "beta", "gamma"]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#[test]
|
|
292
|
+
fn block_array_tags_parse_identically() {
|
|
293
|
+
let text = "---\nid: x\ntags:\n - alpha\n - beta\n - gamma\n---\n";
|
|
294
|
+
let item = parse(text).unwrap();
|
|
295
|
+
assert_eq!(item.tags, vec!["alpha", "beta", "gamma"]);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#[test]
|
|
299
|
+
fn flow_and_block_arrays_identical() {
|
|
300
|
+
let flow = "---\nid: x\ntags: [alpha, beta]\ndepends_on: [dep-1, dep-2]\n---\n";
|
|
301
|
+
let block =
|
|
302
|
+
"---\nid: x\ntags:\n - alpha\n - beta\ndepends_on:\n - dep-1\n - dep-2\n---\n";
|
|
303
|
+
let flow_item = parse(flow).unwrap();
|
|
304
|
+
let block_item = parse(block).unwrap();
|
|
305
|
+
assert_eq!(flow_item.tags, block_item.tags);
|
|
306
|
+
assert_eq!(flow_item.depends_on, block_item.depends_on);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#[test]
|
|
310
|
+
fn null_string_literal_normalizes_to_none() {
|
|
311
|
+
let text = "---\nid: x\nparent: \"null\"\nrelease_binding: null\ngate_origin: null\n---\n";
|
|
312
|
+
let item = parse(text).unwrap();
|
|
313
|
+
assert!(
|
|
314
|
+
item.parent.is_none(),
|
|
315
|
+
"literal `\"null\"` string should be None"
|
|
316
|
+
);
|
|
317
|
+
assert!(item.release_binding.is_none());
|
|
318
|
+
assert!(item.gate_origin.is_none());
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#[test]
|
|
322
|
+
fn empty_string_optional_normalizes_to_none() {
|
|
323
|
+
let text = "---\nid: x\nparent: \"\"\nrelease_binding: \"\"\n---\n";
|
|
324
|
+
let item = parse(text).unwrap();
|
|
325
|
+
assert!(item.parent.is_none());
|
|
326
|
+
assert!(item.release_binding.is_none());
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
#[test]
|
|
330
|
+
fn missing_optional_fields_are_none() {
|
|
331
|
+
let text = "---\nid: x\n---\n";
|
|
332
|
+
let item = parse(text).unwrap();
|
|
333
|
+
assert!(item.kind.is_none());
|
|
334
|
+
assert!(item.stage.is_none());
|
|
335
|
+
assert!(item.parent.is_none());
|
|
336
|
+
assert!(item.release_binding.is_none());
|
|
337
|
+
assert!(item.gate_origin.is_none());
|
|
338
|
+
assert!(item.research_refs.is_empty());
|
|
339
|
+
assert!(item.research_origin.is_none());
|
|
340
|
+
assert!(item.created.is_none());
|
|
341
|
+
assert!(item.updated.is_none());
|
|
342
|
+
assert!(item.tags.is_empty());
|
|
343
|
+
assert!(item.depends_on.is_empty());
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#[test]
|
|
347
|
+
fn missing_id_returns_parse_error() {
|
|
348
|
+
let text = "---\nkind: feature\nstage: implementing\n---\n";
|
|
349
|
+
let err = parse(text).unwrap_err();
|
|
350
|
+
assert!(
|
|
351
|
+
err.reason.contains("id") || err.reason.contains("missing"),
|
|
352
|
+
"error should mention id, got: {}",
|
|
353
|
+
err.reason
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
#[test]
|
|
358
|
+
fn invalid_yaml_returns_parse_error() {
|
|
359
|
+
let text = "---\nid: [not valid: yaml: {\n---\n";
|
|
360
|
+
assert!(parse(text).is_err());
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
#[test]
|
|
364
|
+
fn no_frontmatter_block_returns_parse_error() {
|
|
365
|
+
let text = "# Just markdown\nNo frontmatter here.\n";
|
|
366
|
+
assert!(parse(text).is_err());
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
#[test]
|
|
370
|
+
fn empty_id_field_returns_parse_error() {
|
|
371
|
+
let text = "---\nid: \"\"\n---\n";
|
|
372
|
+
let err = parse(text).unwrap_err();
|
|
373
|
+
assert!(err.reason.contains("empty"));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
#[test]
|
|
377
|
+
fn whitespace_scalar_strips() {
|
|
378
|
+
// YAML should strip surrounding quotes; the scalar value is the content
|
|
379
|
+
let text = "---\nid: my-id\nparent: \" my-parent \"\n---\n";
|
|
380
|
+
let item = parse(text).unwrap();
|
|
381
|
+
// serde_yaml_ng should give us " my-parent " (with spaces); we normalize trim
|
|
382
|
+
// Actually YAML quoted strings preserve interior whitespace but strip the quotes.
|
|
383
|
+
// After our normalize_optional, leading/trailing spaces are trimmed.
|
|
384
|
+
assert_eq!(item.parent.as_deref(), Some("my-parent"));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
#[test]
|
|
388
|
+
fn tier_and_paths_stored_correctly() {
|
|
389
|
+
let text = "---\nid: x\n---\n";
|
|
390
|
+
let abs = p("/repo/.work/archive/old.md");
|
|
391
|
+
let rel = p(".work/archive/old.md");
|
|
392
|
+
let item = parse_item(&abs, &rel, Tier::Archive, text).unwrap();
|
|
393
|
+
assert_eq!(item.tier, Tier::Archive);
|
|
394
|
+
assert_eq!(item.path, abs);
|
|
395
|
+
assert_eq!(item.rel_path, rel);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
#[test]
|
|
399
|
+
fn research_refs_flow_array_parses() {
|
|
400
|
+
let text = "---\nid: x\nresearch_refs: [slug-a, slug-b]\n---\n";
|
|
401
|
+
let item = parse(text).unwrap();
|
|
402
|
+
assert_eq!(item.research_refs, vec!["slug-a", "slug-b"]);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
#[test]
|
|
406
|
+
fn research_refs_block_array_parses_identically() {
|
|
407
|
+
let text = "---\nid: x\nresearch_refs:\n - slug-a\n - slug-b\n---\n";
|
|
408
|
+
let item = parse(text).unwrap();
|
|
409
|
+
assert_eq!(item.research_refs, vec!["slug-a", "slug-b"]);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
#[test]
|
|
413
|
+
fn research_refs_missing_yields_empty_vec() {
|
|
414
|
+
let text = "---\nid: x\n---\n";
|
|
415
|
+
let item = parse(text).unwrap();
|
|
416
|
+
assert!(item.research_refs.is_empty());
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
#[test]
|
|
420
|
+
fn research_origin_null_literal_normalizes_to_none() {
|
|
421
|
+
let text = "---\nid: x\nresearch_origin: \"null\"\n---\n";
|
|
422
|
+
let item = parse(text).unwrap();
|
|
423
|
+
assert!(
|
|
424
|
+
item.research_origin.is_none(),
|
|
425
|
+
"literal `\"null\"` should normalize to None"
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
#[test]
|
|
430
|
+
fn research_origin_yaml_null_normalizes_to_none() {
|
|
431
|
+
let text = "---\nid: x\nresearch_origin: null\n---\n";
|
|
432
|
+
let item = parse(text).unwrap();
|
|
433
|
+
assert!(item.research_origin.is_none());
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
#[test]
|
|
437
|
+
fn research_origin_empty_string_normalizes_to_none() {
|
|
438
|
+
let text = "---\nid: x\nresearch_origin: \"\"\n---\n";
|
|
439
|
+
let item = parse(text).unwrap();
|
|
440
|
+
assert!(item.research_origin.is_none());
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
#[test]
|
|
444
|
+
fn research_origin_missing_normalizes_to_none() {
|
|
445
|
+
let text = "---\nid: x\n---\n";
|
|
446
|
+
let item = parse(text).unwrap();
|
|
447
|
+
assert!(item.research_origin.is_none());
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
#[test]
|
|
451
|
+
fn research_origin_value_preserved() {
|
|
452
|
+
let text = "---\nid: x\nresearch_origin: pos-x\n---\n";
|
|
453
|
+
let item = parse(text).unwrap();
|
|
454
|
+
assert_eq!(item.research_origin.as_deref(), Some("pos-x"));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
#[test]
|
|
458
|
+
fn scan_origin_null_literal_normalizes_to_none() {
|
|
459
|
+
let text = "---\nid: x\nscan_origin: \"null\"\n---\n";
|
|
460
|
+
let item = parse(text).unwrap();
|
|
461
|
+
assert!(
|
|
462
|
+
item.scan_origin.is_none(),
|
|
463
|
+
"literal `\"null\"` should normalize to None"
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
#[test]
|
|
468
|
+
fn scan_origin_yaml_null_normalizes_to_none() {
|
|
469
|
+
let text = "---\nid: x\nscan_origin: null\n---\n";
|
|
470
|
+
let item = parse(text).unwrap();
|
|
471
|
+
assert!(item.scan_origin.is_none());
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
#[test]
|
|
475
|
+
fn scan_origin_empty_string_normalizes_to_none() {
|
|
476
|
+
let text = "---\nid: x\nscan_origin: \"\"\n---\n";
|
|
477
|
+
let item = parse(text).unwrap();
|
|
478
|
+
assert!(item.scan_origin.is_none());
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
#[test]
|
|
482
|
+
fn scan_origin_missing_normalizes_to_none() {
|
|
483
|
+
let text = "---\nid: x\n---\n";
|
|
484
|
+
let item = parse(text).unwrap();
|
|
485
|
+
assert!(item.scan_origin.is_none());
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
#[test]
|
|
489
|
+
fn scan_origin_value_preserved() {
|
|
490
|
+
let text = "---\nid: x\nscan_origin: scan-x\n---\n";
|
|
491
|
+
let item = parse(text).unwrap();
|
|
492
|
+
assert_eq!(item.scan_origin.as_deref(), Some("scan-x"));
|
|
493
|
+
}
|
|
494
|
+
}
|