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::Foreign { workspace, .. } => Err(unresolved(&format!(
193 "the anchor names a document in the workspace `{workspace}`, which prov cannot see from here"
194 ))),
195 }
196}
197
198fn unreached(kind: &NodeKind) -> Option<String> {
206 match kind {
207 NodeKind::Doc => None,
208 NodeKind::Missing => Some("no document exists there".to_string()),
209 NodeKind::Unreadable(why) => Some(format!("that document could not be read: {why}")),
210 NodeKind::Cycle => Some("that document contains itself".to_string()),
211 NodeKind::UnresolvedId(id) => Some(format!("no document is registered under `{}`", id.0)),
212 NodeKind::AmbiguousAlias(name) => Some(format!("several documents are titled `{name}`")),
213 NodeKind::Foreign { workspace, .. } => Some(format!(
214 "it names a document in the workspace `{workspace}`, which prov cannot see from here"
215 )),
216 }
217}
218
219fn collect(node: &prov_graph::graph::Node, skip_root: bool, out: &mut Vec<PathBuf>) {
230 if !skip_root && matches!(node.kind, NodeKind::Doc) {
231 out.push(node.path.clone());
232 }
233 for child in &node.children {
234 collect(child, false, out);
235 }
236}
237
238#[cfg(all(test, feature = "yaml"))]
241mod tests {
242 use super::*;
243 use crate::filter::Condition;
244 use crate::spec::Grouping;
245 use prov_graph::exec::block_on;
246 use prov_graph::fs::StdFs;
247 use prov_graph::graph::ReadSettings;
248 use prov_graph::index::NoIndex;
249
250 fn write(dir: &Path, rel: &str, text: &str) {
251 let p = dir.join(rel);
252 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
253 std::fs::write(p, text).unwrap();
254 }
255
256 fn tempdir(tag: &str) -> PathBuf {
257 let dir = std::env::temp_dir().join(format!("prov-select-{tag}-{}", std::process::id()));
258 let _ = std::fs::remove_dir_all(&dir);
259 std::fs::create_dir_all(&dir).unwrap();
260 dir
261 }
262
263 fn journal(tag: &str) -> PathBuf {
267 let dir = tempdir(tag);
268 write(
269 &dir,
270 "index.md",
271 "---\ntitle: Home\ncontents:\n- daily.md\n- readme.md\n---\n",
272 );
273 write(
274 &dir,
275 "readme.md",
276 "---\ntitle: Readme\npart_of: index.md\ncreated: 2026-01-02\n---\n",
277 );
278 write(
279 &dir,
280 "daily.md",
281 "---\ntitle: Daily\npart_of: index.md\ncontents:\n- daily/2026.md\n---\n",
282 );
283 write(
284 &dir,
285 "daily/2026.md",
286 "---\ntitle: '2026'\npart_of: ../daily.md\ncontents:\n- 07-24.md\n- 08-01.md\n---\n",
287 );
288 write(
289 &dir,
290 "daily/07-24.md",
291 "---\ntitle: July 24\npart_of: 2026.md\ndate_of_document: 2026-07-24\ndraft: true\n---\n",
292 );
293 write(
294 &dir,
295 "daily/08-01.md",
296 "---\ntitle: August 1\npart_of: 2026.md\ncreated: 2026-08-01T09:00:00Z\n---\n",
297 );
298 dir
299 }
300
301 fn graph(dir: &Path) -> Graph<StdFs, NoIndex> {
302 Graph::new(StdFs, dir, NoIndex, ReadSettings::default())
303 }
304
305 fn spec(under: Option<&str>, filter: Option<Condition>) -> ViewSpec {
306 ViewSpec {
307 name: "daily".into(),
308 label: None,
309 icon: None,
310 group: Grouping {
311 keys: vec!["date_of_document".into(), "created".into()],
312 by: None,
313 },
314 under: under.map(str::to_string),
315 filter,
316 nest: None,
317 }
318 }
319
320 fn paths(selection: &Selection) -> Vec<String> {
321 selection
322 .rows
323 .iter()
324 .map(|r| r.path.display().to_string())
325 .collect()
326 }
327
328 #[test]
332 fn an_anchor_scopes_the_selection_to_its_subtree_and_excludes_itself() {
333 let dir = journal("scope");
334 let selection = block_on(select(
335 &graph(&dir),
336 &spec(Some("daily.md"), None),
337 "index.md",
338 ))
339 .expect("a selection");
340 assert_eq!(
341 paths(&selection),
342 ["daily/07-24.md", "daily/08-01.md", "daily/2026.md"]
343 );
344 }
345
346 #[test]
354 fn an_unscoped_view_covers_the_whole_workspace() {
355 let dir = journal("unscoped");
356 let selection =
357 block_on(select(&graph(&dir), &spec(None, None), "index.md")).expect("a selection");
358 assert_eq!(
359 paths(&selection),
360 [
361 "daily/07-24.md",
362 "daily/08-01.md",
363 "daily/2026.md",
364 "daily.md",
365 "index.md",
366 "readme.md",
367 ]
368 );
369 }
370
371 #[test]
375 fn scope_survives_moving_the_subtree() {
376 let dir = journal("moved");
377 std::fs::rename(dir.join("daily"), dir.join("archive")).unwrap();
378 write(
379 &dir,
380 "daily.md",
381 "---\ntitle: Daily\npart_of: index.md\ncontents:\n- archive/2026.md\n---\n",
382 );
383 write(
384 &dir,
385 "archive/2026.md",
386 "---\ntitle: '2026'\npart_of: ../daily.md\ncontents:\n- 07-24.md\n- 08-01.md\n---\n",
387 );
388
389 let selection = block_on(select(
390 &graph(&dir),
391 &spec(Some("daily.md"), None),
392 "index.md",
393 ))
394 .expect("a selection");
395 assert_eq!(
396 paths(&selection),
397 ["archive/07-24.md", "archive/08-01.md", "archive/2026.md"]
398 );
399 }
400
401 #[test]
404 fn a_where_condition_narrows_the_selection() {
405 let dir = journal("filter");
406 let no_drafts = Condition::Not(Box::new(Condition::Has("draft".into())));
407 let selection = block_on(select(
408 &graph(&dir),
409 &spec(Some("daily.md"), Some(no_drafts)),
410 "index.md",
411 ))
412 .expect("a selection");
413 assert_eq!(paths(&selection), ["daily/08-01.md", "daily/2026.md"]);
414
415 let matches_nothing = Condition::Has("nonexistent".into());
416 let empty = block_on(select(
417 &graph(&dir),
418 &spec(Some("daily.md"), Some(matches_nothing)),
419 "index.md",
420 ))
421 .expect("an empty selection is not an error");
422 assert!(empty.is_empty());
423 }
424
425 #[test]
428 fn rows_carry_metadata_so_grouping_needs_no_second_read() {
429 let dir = journal("meta");
430 let spec = spec(Some("daily.md"), None);
431 let selection = block_on(select(&graph(&dir), &spec, "index.md")).expect("a selection");
432
433 let entry = selection
434 .rows
435 .iter()
436 .find(|r| r.path.ends_with("07-24.md"))
437 .expect("the entry");
438 assert_eq!(entry.title(), Some("July 24"));
439
440 let rows = crate::group(&selection, &spec.group);
442 assert_eq!(rows.len(), 3, "documents, not placements");
443 assert_eq!(rows.groups.len(), 2);
444 }
445
446 #[test]
449 fn an_unresolvable_anchor_is_an_error_not_an_empty_selection() {
450 let dir = journal("dead-anchor");
451 let by_path = spec(Some("[Gone](nowhere.md)"), None);
454 let err = block_on(select(&graph(&dir), &by_path, "index.md")).unwrap_err();
455 let Error::AnchorUnresolved { under, why, .. } = &err else {
456 panic!("got {err:?}");
457 };
458 assert_eq!(under, "[Gone](nowhere.md)");
459 assert_eq!(why, "no document exists there");
460
461 let by_id = spec(Some("[Gone](id:abcd123)"), None);
462 let err = block_on(select(&graph(&dir), &by_id, "index.md")).unwrap_err();
463 let Error::AnchorUnresolved { view, under, .. } = &err else {
464 panic!("got {err:?}");
465 };
466 assert_eq!(view, "daily");
467 assert_eq!(under, "[Gone](id:abcd123)");
468 assert!(err.to_string().contains("is registered under the id"));
469 }
470
471 #[test]
474 fn selection_is_deterministic() {
475 let dir = journal("stable");
476 let spec = spec(Some("daily.md"), None);
477 let g = graph(&dir);
478 let first = block_on(select(&g, &spec, "index.md")).unwrap();
479 let second = block_on(select(&g, &spec, "index.md")).unwrap();
480 assert_eq!(first, second);
481 }
482}