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
// SPDX-License-Identifier: GPL-3.0-only
//! Shared metadata-list substrate for authored numeric entities.
//!
//! Slices and ADRs are both numeric directories under a tree root, each holding
//! a sister `<stem>-<id>.toml` carrying the same four list fields. The reader,
//! status filter, and aligned formatter are status/path-parametric — they carry
//! zero per-kind knowledge — so they live here once and every kind calls them
//! (design SL-006 D4), parameterised by the toml *stem* (`"slice"` / `"adr"`).
//!
//! The stem is distinct from `entity::Kind.prefix` (`"SL"` / `"ADR"`): the stem
//! names the file (`slice-007.toml`), the prefix the canonical id (`SL-007`).
//!
//! This is CLI presentation plus an authored-toml reader — deliberately *not*
//! `entity.rs`, which stays a kind-blind scaffold engine free of presentation.
//! The clock seam lives in `crate::clock`; nothing here reads wall time.
//!
//! The generic table layout (`render_table`) used to live here too; SL-025
//! relocated it to the kind-blind read spine (`crate::listing`), which serves the
//! named (memory) and own-struct (backlog) kinds as well as these numeric ones.
//! The numeric-kind list grid (formerly `format_list`) now lives on the spine too,
//! per-kind; `meta` keeps only the authored-toml reader and the sort-by-id helper.
use std::fs;
use std::path::Path;
use anyhow::Context;
use serde::Deserialize;
use crate::dtoml;
use crate::entity;
/// The fields a reader extracts from a `<stem>-<id>.toml`. Unknown keys (the
/// `[relationships]` table, future sections) are ignored and preserved on disk.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct Meta {
pub(crate) id: u32,
pub(crate) slug: String,
pub(crate) title: String,
pub(crate) status: String,
#[serde(default)]
pub(crate) tags: Vec<String>,
}
/// Parse the `Meta` of a single entity by id, reading `<stem>-<id>.toml` under
/// its numeric dir in `tree_root`.
pub(crate) fn read_meta(
tree_root: &Path,
stem: &str,
id: u32,
prefix: &str,
) -> anyhow::Result<Meta> {
let name = format!("{id:03}");
let path = tree_root.join(&name).join(format!("{stem}-{name}.toml"));
let text = fs::read_to_string(&path)
.with_context(|| format!("{stem} {name} not found at {}", path.display()))?;
dtoml::parse_entity_toml(&text, prefix, id)
.with_context(|| format!("Failed to parse {}", path.display()))
}
/// The id, and only the id, of a `<stem>-<id>.toml` — the scan-path reader
/// (SL-040 D2). Serde ignores every other key, so a kind whose authored toml is
/// intentionally **status-less** (review — its status is derived, D-C8) scans for
/// `.id` cleanly, while the strict [`Meta`] above stays unchanged: a genuinely
/// corrupt status-bearing toml with a missing `status` still hard-fails at every
/// `read_meta` caller (`show`/`list`/render). Leniency is confined to this path —
/// `validate`'s id scan, which only ever needs the `id` (design §5 / R-a).
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct IdOnly {
pub(crate) id: u32,
}
/// Read just the `id` of a single entity's `<stem>-<id>.toml` — the [`IdOnly`]
/// scan-path reader. Used by `integrity::scan_kind`, the one place a status-less
/// kind (review) must be read without tripping the strict [`Meta`] (D2).
pub(crate) fn read_id(tree_root: &Path, stem: &str, id: u32, prefix: &str) -> anyhow::Result<u32> {
let name = format!("{id:03}");
let path = tree_root.join(&name).join(format!("{stem}-{name}.toml"));
let text = fs::read_to_string(&path)
.with_context(|| format!("{stem} {name} not found at {}", path.display()))?;
let parsed: IdOnly = dtoml::parse_entity_toml(&text, prefix, id)
.with_context(|| format!("Failed to parse {}", path.display()))?;
Ok(parsed.id)
}
/// Read and parse every `<stem>-<id>.toml` under `tree_root`. `scan_ids` yields
/// numeric dirs only, so `<id>-<slug>` symlinks and non-numeric entries are
/// skipped.
pub(crate) fn read_metas(tree_root: &Path, stem: &str, prefix: &str) -> anyhow::Result<Vec<Meta>> {
let mut metas = Vec::new();
for id in entity::scan_ids(tree_root)? {
metas.push(read_meta(tree_root, stem, id, prefix)?);
}
Ok(metas)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn meta(id: u32, status: &str, slug: &str, title: &str) -> Meta {
Meta {
id,
slug: slug.to_string(),
title: title.to_string(),
status: status.to_string(),
tags: Vec::new(),
}
}
/// Write a minimal `<stem>-<id>.toml` carrying the four list fields under its
/// numeric dir — a true unit fixture, independent of any kind's scaffold.
fn write_meta_toml(tree_root: &Path, stem: &str, id: u32, status: &str, slug: &str) {
let name = format!("{id:03}");
let dir = tree_root.join(&name);
fs::create_dir_all(&dir).unwrap();
let body = format!(
"id = {id}\nslug = \"{slug}\"\ntitle = \"Title {id}\"\nstatus = \"{status}\"\n"
);
fs::write(dir.join(format!("{stem}-{name}.toml")), body).unwrap();
}
#[test]
fn read_meta_reads_the_stem_toml() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_meta_toml(root, "slice", 1, "proposed", "my-slug");
let m = read_meta(root, "slice", 1, "TK").unwrap();
assert_eq!(m, meta(1, "proposed", "my-slug", "Title 1"));
}
#[test]
fn read_meta_is_parameterised_by_stem() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
// same id, different stem — the stem selects the file.
write_meta_toml(root, "adr", 7, "accepted", "use-rust");
let m = read_meta(root, "adr", 7, "TK").unwrap();
assert_eq!(m.status, "accepted");
// the wrong stem does not find it
assert!(read_meta(root, "slice", 7, "TK").is_err());
}
/// Write a status-LESS `<stem>-<id>.toml` carrying only `id`/`slug`/`title`
/// — review's intentionally derived-status authored shape (SL-040 D2).
fn write_statusless_toml(tree_root: &Path, stem: &str, id: u32) {
let name = format!("{id:03}");
let dir = tree_root.join(&name);
fs::create_dir_all(&dir).unwrap();
let body = format!("id = {id}\nslug = \"sl\"\ntitle = \"T {id}\"\n");
fs::write(dir.join(format!("{stem}-{name}.toml")), body).unwrap();
}
/// SL-040 D2 (VT-1, the scan-path half): the id-only reader scans a
/// status-less toml for `.id` cleanly — review need not seed a derived status.
#[test]
fn read_id_scans_a_statusless_toml() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_statusless_toml(root, "review", 7);
assert_eq!(read_id(root, "review", 7, "TK").unwrap(), 7);
}
/// SL-040 D2 (VT-1, the preserved-invariant half): the strict `Meta` reader
/// still HARD-FAILS on a status-less status-BEARING toml — leniency is confined
/// to `read_id`; `read_meta` keeps the "missing status is corruption" contract.
#[test]
fn read_meta_still_hard_fails_on_a_missing_status() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_statusless_toml(root, "slice", 7);
let err = read_meta(root, "slice", 7, "TK").unwrap_err();
assert!(
err.to_string().contains("Failed to parse"),
"missing status must be a hard parse error: {err}"
);
}
#[test]
fn read_metas_collects_every_numeric_and_skips_the_rest() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_meta_toml(root, "slice", 2, "proposed", "two");
write_meta_toml(root, "slice", 1, "done", "one");
// a `<id>-<slug>` symlink alias and a stray non-numeric dir are ignored
// by scan_ids (numeric dirs only). read_metas yields scan order, not
// sorted — each kind owns ordering on the spine — so compare as a set.
std::os::unix::fs::symlink("001", root.join("001-one")).unwrap();
fs::create_dir_all(root.join("notes")).unwrap();
let mut ids: Vec<u32> = read_metas(root, "slice", "TK")
.unwrap()
.iter()
.map(|m| m.id)
.collect();
ids.sort_unstable();
assert_eq!(ids, vec![1, 2]);
}
}