1use std::path::{Path, PathBuf};
27
28use prov_graph::fs::ReadStorage;
29use prov_graph::graph::{Graph, NodeKind, Target, TreeOptions};
30use prov_graph::index::IdIndex;
31use prov_graph::link::Link;
32use prov_graph::meta::Value;
33
34use crate::error::{Error, Result};
35use crate::spec::ViewSpec;
36
37#[derive(Debug, Clone, PartialEq)]
43pub struct Row {
44 pub path: PathBuf,
47 pub meta: Value,
49}
50
51impl Row {
52 pub fn title(&self) -> Option<&str> {
54 self.meta.get("title").and_then(Value::as_str)
55 }
56}
57
58#[derive(Debug, Clone, PartialEq)]
66pub struct Selection {
67 pub view: String,
69 pub rows: Vec<Row>,
71}
72
73impl Selection {
74 pub fn len(&self) -> usize {
76 self.rows.len()
77 }
78
79 pub fn is_empty(&self) -> bool {
81 self.rows.is_empty()
82 }
83}
84
85pub async fn select<FS: ReadStorage, Ix: IdIndex>(
99 graph: &Graph<FS, Ix>,
100 spec: &ViewSpec,
101 root_doc: impl AsRef<Path>,
102) -> Result<Selection> {
103 let root_doc = root_doc.as_ref();
104 let _scope = graph.read_scope();
108
109 let anchor = match &spec.under {
110 Some(under) => resolve_anchor(graph, spec, root_doc, under)?,
111 None => root_doc.to_path_buf(),
112 };
113
114 let tree = graph
119 .tree_with(
120 &anchor,
121 TreeOptions {
122 ignore_missing: true,
123 },
124 )
125 .await?;
126
127 if spec.under.is_some()
132 && let Some(why) = unreached(&tree.kind)
133 {
134 return Err(Error::AnchorUnresolved {
135 view: spec.name.clone(),
136 under: spec.under.clone().unwrap_or_default(),
137 why,
138 });
139 }
140
141 let mut scope: Vec<PathBuf> = Vec::new();
142 collect(&tree, spec.under.is_some(), &mut scope);
143 scope.sort();
148 scope.dedup();
149
150 let mut rows = Vec::with_capacity(scope.len());
151 for path in scope {
152 let doc = graph.document(&path).await?;
153 let row = Row {
154 path,
155 meta: doc.meta,
156 };
157 if spec.filter.as_ref().is_none_or(|c| c.matches(&row.meta)) {
158 rows.push(row);
159 }
160 }
161
162 Ok(Selection {
163 view: spec.name.clone(),
164 rows,
165 })
166}
167
168fn resolve_anchor<FS, Ix: IdIndex>(
170 graph: &Graph<FS, Ix>,
171 spec: &ViewSpec,
172 root_doc: &Path,
173 under: &str,
174) -> Result<PathBuf> {
175 let unresolved = |why: &str| Error::AnchorUnresolved {
176 view: spec.name.clone(),
177 under: under.to_string(),
178 why: why.to_string(),
179 };
180 match graph.resolve_link(root_doc, &Link::parse(under)) {
181 Target::Path(path) => Ok(path),
182 Target::UnresolvedId(id) => Err(unresolved(&format!(
183 "no document is registered under the id `{}`",
184 id.0
185 ))),
186 Target::AmbiguousAlias(name) => Err(unresolved(&format!(
187 "several documents are titled `{name}`, so the anchor names no one of them"
188 ))),
189 Target::External => Err(unresolved(
190 "an anchor must name a document in this workspace, and this is a URL",
191 )),
192 Target::SameDocument => Err(unresolved(
193 "an anchor must name a document, and this names only a place inside one",
194 )),
195 Target::Foreign { workspace, .. } => Err(unresolved(&format!(
196 "the anchor names a document in the workspace `{workspace}`, which prov cannot see from here"
197 ))),
198 }
199}
200
201fn unreached(kind: &NodeKind) -> Option<String> {
209 match kind {
210 NodeKind::Doc => None,
211 NodeKind::Missing => Some("no document exists there".to_string()),
212 NodeKind::Unreadable(why) => Some(format!("that document could not be read: {why}")),
213 NodeKind::Cycle => Some("that document contains itself".to_string()),
214 NodeKind::UnresolvedId(id) => Some(format!("no document is registered under `{}`", id.0)),
215 NodeKind::AmbiguousAlias(name) => Some(format!("several documents are titled `{name}`")),
216 NodeKind::Foreign { workspace, .. } => Some(format!(
217 "it names a document in the workspace `{workspace}`, which prov cannot see from here"
218 )),
219 }
220}
221
222fn collect(node: &prov_graph::graph::Node, skip_root: bool, out: &mut Vec<PathBuf>) {
233 if !skip_root && matches!(node.kind, NodeKind::Doc) {
234 out.push(node.path.clone());
235 }
236 for child in &node.children {
237 collect(child, false, out);
238 }
239}
240
241#[cfg(all(test, feature = "yaml"))]
244mod tests {
245 use super::*;
246 use crate::filter::Condition;
247 use crate::spec::Grouping;
248 use prov_graph::exec::block_on;
249 use prov_graph::fs::StdFs;
250 use prov_graph::graph::ReadSettings;
251 use prov_graph::index::NoIndex;
252
253 use prov_testkit::write;
254 fn tempdir(tag: &str) -> PathBuf {
255 prov_testkit::scratch("select", tag)
256 }
257
258 fn journal(tag: &str) -> PathBuf {
262 let dir = tempdir(tag);
263 write(
264 &dir,
265 "index.md",
266 "---\ntitle: Home\ncontents:\n- daily.md\n- readme.md\n---\n",
267 );
268 write(
269 &dir,
270 "readme.md",
271 "---\ntitle: Readme\npart_of: index.md\ncreated: 2026-01-02\n---\n",
272 );
273 write(
274 &dir,
275 "daily.md",
276 "---\ntitle: Daily\npart_of: index.md\ncontents:\n- daily/2026.md\n---\n",
277 );
278 write(
279 &dir,
280 "daily/2026.md",
281 "---\ntitle: '2026'\npart_of: ../daily.md\ncontents:\n- 07-24.md\n- 08-01.md\n---\n",
282 );
283 write(
284 &dir,
285 "daily/07-24.md",
286 "---\ntitle: July 24\npart_of: 2026.md\ndate_of_document: 2026-07-24\ndraft: true\n---\n",
287 );
288 write(
289 &dir,
290 "daily/08-01.md",
291 "---\ntitle: August 1\npart_of: 2026.md\ncreated: 2026-08-01T09:00:00Z\n---\n",
292 );
293 dir
294 }
295
296 fn graph(dir: &Path) -> Graph<StdFs, NoIndex> {
297 Graph::new(StdFs, dir, NoIndex, ReadSettings::default())
298 }
299
300 fn spec(under: Option<&str>, filter: Option<Condition>) -> ViewSpec {
301 ViewSpec {
302 name: "daily".into(),
303 label: None,
304 icon: None,
305 group: Grouping {
306 keys: vec!["date_of_document".into(), "created".into()],
307 by: None,
308 },
309 under: under.map(str::to_string),
310 filter,
311 nest: None,
312 }
313 }
314
315 fn paths(selection: &Selection) -> Vec<String> {
316 selection
317 .rows
318 .iter()
319 .map(|r| r.path.display().to_string())
320 .collect()
321 }
322
323 #[test]
327 fn an_anchor_scopes_the_selection_to_its_subtree_and_excludes_itself() {
328 let dir = journal("scope");
329 let selection = block_on(select(
330 &graph(&dir),
331 &spec(Some("daily.md"), None),
332 "index.md",
333 ))
334 .expect("a selection");
335 assert_eq!(
336 paths(&selection),
337 ["daily/07-24.md", "daily/08-01.md", "daily/2026.md"]
338 );
339 }
340
341 #[test]
349 fn an_unscoped_view_covers_the_whole_workspace() {
350 let dir = journal("unscoped");
351 let selection =
352 block_on(select(&graph(&dir), &spec(None, None), "index.md")).expect("a selection");
353 assert_eq!(
354 paths(&selection),
355 [
356 "daily/07-24.md",
357 "daily/08-01.md",
358 "daily/2026.md",
359 "daily.md",
360 "index.md",
361 "readme.md",
362 ]
363 );
364 }
365
366 #[test]
370 fn scope_survives_moving_the_subtree() {
371 let dir = journal("moved");
372 std::fs::rename(dir.join("daily"), dir.join("archive")).unwrap();
373 write(
374 &dir,
375 "daily.md",
376 "---\ntitle: Daily\npart_of: index.md\ncontents:\n- archive/2026.md\n---\n",
377 );
378 write(
379 &dir,
380 "archive/2026.md",
381 "---\ntitle: '2026'\npart_of: ../daily.md\ncontents:\n- 07-24.md\n- 08-01.md\n---\n",
382 );
383
384 let selection = block_on(select(
385 &graph(&dir),
386 &spec(Some("daily.md"), None),
387 "index.md",
388 ))
389 .expect("a selection");
390 assert_eq!(
391 paths(&selection),
392 ["archive/07-24.md", "archive/08-01.md", "archive/2026.md"]
393 );
394 }
395
396 #[test]
399 fn a_where_condition_narrows_the_selection() {
400 let dir = journal("filter");
401 let no_drafts = Condition::Not(Box::new(Condition::Has("draft".into())));
402 let selection = block_on(select(
403 &graph(&dir),
404 &spec(Some("daily.md"), Some(no_drafts)),
405 "index.md",
406 ))
407 .expect("a selection");
408 assert_eq!(paths(&selection), ["daily/08-01.md", "daily/2026.md"]);
409
410 let matches_nothing = Condition::Has("nonexistent".into());
411 let empty = block_on(select(
412 &graph(&dir),
413 &spec(Some("daily.md"), Some(matches_nothing)),
414 "index.md",
415 ))
416 .expect("an empty selection is not an error");
417 assert!(empty.is_empty());
418 }
419
420 #[test]
423 fn rows_carry_metadata_so_grouping_needs_no_second_read() {
424 let dir = journal("meta");
425 let spec = spec(Some("daily.md"), None);
426 let selection = block_on(select(&graph(&dir), &spec, "index.md")).expect("a selection");
427
428 let entry = selection
429 .rows
430 .iter()
431 .find(|r| r.path.ends_with("07-24.md"))
432 .expect("the entry");
433 assert_eq!(entry.title(), Some("July 24"));
434
435 let rows = crate::group(&selection, &spec.group);
437 assert_eq!(rows.len(), 3, "documents, not placements");
438 assert_eq!(rows.groups.len(), 2);
439 }
440
441 #[test]
444 fn an_unresolvable_anchor_is_an_error_not_an_empty_selection() {
445 let dir = journal("dead-anchor");
446 let by_path = spec(Some("[Gone](nowhere.md)"), None);
449 let err = block_on(select(&graph(&dir), &by_path, "index.md")).unwrap_err();
450 let Error::AnchorUnresolved { under, why, .. } = &err else {
451 panic!("got {err:?}");
452 };
453 assert_eq!(under, "[Gone](nowhere.md)");
454 assert_eq!(why, "no document exists there");
455
456 let by_id = spec(Some("[Gone](id:abcd123)"), None);
457 let err = block_on(select(&graph(&dir), &by_id, "index.md")).unwrap_err();
458 let Error::AnchorUnresolved { view, under, .. } = &err else {
459 panic!("got {err:?}");
460 };
461 assert_eq!(view, "daily");
462 assert_eq!(under, "[Gone](id:abcd123)");
463 assert!(err.to_string().contains("is registered under the id"));
464 }
465
466 #[test]
469 fn selection_is_deterministic() {
470 let dir = journal("stable");
471 let spec = spec(Some("daily.md"), None);
472 let g = graph(&dir);
473 let first = block_on(select(&g, &spec, "index.md")).unwrap();
474 let second = block_on(select(&g, &spec, "index.md")).unwrap();
475 assert_eq!(first, second);
476 }
477}