Skip to main content

opys_engine/commands/
block.rs

1//! `opys block` / `opys unblock` — record a directional blocker relationship
2//! between two documents of any type, through the corpus store.
3//!
4//! Marking `<id>` blocked by `<by>` writes `<by>` into `<id>`'s `blocked_by` map
5//! and `<id>` into `<by>`'s `blocks` map (the bidirectional link). When the
6//! blocked document's type has a `blocked` status, it is auto-set — the blocker
7//! link itself serves as the `blocked_reason`.
8
9use crate::commands::{expand_ids, for_each_id, maybe_sync, touch};
10use crate::error::{usage, OpysError, Result};
11use crate::frontmatter::Frontmatter;
12use crate::project::Project;
13use crate::refs;
14use crate::store::Store;
15use crate::Ctx;
16
17/// Mark `id` as blocked by `by`, linking both directions. Does not flush/print/
18/// sync — the shared core for the CLI wrapper and the TUI.
19pub fn block_core(prj: &Project, store: &mut Store, id: &str, by: &str) -> Result<()> {
20    if id == by {
21        return Err(usage("an item cannot block itself"));
22    }
23    let id_title = store
24        .title_of(id)?
25        .ok_or_else(|| OpysError::NotFound { id: id.to_string() })?;
26    let by_title = store
27        .title_of(by)?
28        .ok_or_else(|| OpysError::NotFound { id: by.to_string() })?;
29
30    let id_blockable = has_status(prj, id, "blocked");
31    edit_doc(prj, store, id, true, |fm| {
32        let mut changed = refs::add_to_map(fm, refs::BLOCKED_BY, by, &by_title);
33        if id_blockable && fm.status() != Some("blocked") {
34            fm.set_str("status", "blocked");
35            changed = true;
36        }
37        if changed {
38            touch(fm);
39        }
40        changed
41    })?;
42    // The reverse `blocks` link on the blocker is derived bookkeeping (the same
43    // link reconcile maintains), so it does not bump the blocker's `updated`.
44    edit_doc(prj, store, by, false, |fm| {
45        refs::add_to_map(fm, refs::BLOCKS, id, &id_title)
46    })?;
47    Ok(())
48}
49
50/// Mark one or more documents as blocked by `by`, linking both directions.
51pub fn block(ctx: &Ctx, ids: &str, by: &str) -> Result<()> {
52    let prj = ctx.open()?;
53    let ids = expand_ids(ids)?;
54    let (mut store, _) = ctx.load(&prj)?;
55    let res = for_each_id(&ids, |id| {
56        block_core(&prj, &mut store, id, by)?;
57        println!("{id} blocked by {by}");
58        Ok(())
59    });
60    ctx.flush(&prj, store)?;
61    maybe_sync(ctx, &prj);
62    res
63}
64
65/// Remove the blocker link from both sides. When the blocked document's type has
66/// a `blocked` status, is left at `blocked` with no remaining blockers and no
67/// free-text `blocked_reason`, its status reverts to `in-progress`. Does not
68/// flush/print/sync — the shared core for the CLI wrapper and the TUI.
69pub fn unblock_core(prj: &Project, store: &mut Store, id: &str, by: &str) -> Result<()> {
70    if store.dkey_opt(id)?.is_none() {
71        return Err(OpysError::NotFound { id: id.to_string() });
72    }
73    let linked = store.has_relation(id, refs::BLOCKED_BY, by)?
74        || store.has_relation(by, refs::BLOCKS, id)?;
75    if !linked {
76        return Err(usage(format!("no blocker '{by}' recorded on '{id}'")));
77    }
78
79    let id_blockable = has_status(prj, id, "blocked") && has_status(prj, id, "in-progress");
80    edit_doc(prj, store, id, true, |fm| {
81        let mut changed = refs::remove_from_map(fm, refs::BLOCKED_BY, by);
82        if id_blockable
83            && fm.status() == Some("blocked")
84            && refs::parse_in(fm, refs::BLOCKED_BY).is_empty()
85            && fm.get_str("blocked_reason").is_none()
86        {
87            fm.set_str("status", "in-progress");
88            changed = true;
89        }
90        if changed {
91            touch(fm);
92        }
93        changed
94    })?;
95    // The blocker may already be gone (a closed doc's struck tombstone); cleaning
96    // the target side is enough, so a missing blocker is not an error.
97    match edit_doc(prj, store, by, false, |fm| {
98        refs::remove_from_map(fm, refs::BLOCKS, id)
99    }) {
100        Ok(()) | Err(OpysError::NotFound { .. }) => {}
101        Err(e) => return Err(e),
102    }
103    Ok(())
104}
105
106/// Remove the blocker link from both sides for one or more documents.
107pub fn unblock(ctx: &Ctx, ids: &str, by: &str) -> Result<()> {
108    let prj = ctx.open()?;
109    let ids = expand_ids(ids)?;
110    let (mut store, _) = ctx.load(&prj)?;
111    let res = for_each_id(&ids, |id| {
112        unblock_core(&prj, &mut store, id, by)?;
113        println!("{id} no longer blocked by {by}");
114        Ok(())
115    });
116    ctx.flush(&prj, store)?;
117    maybe_sync(ctx, &prj);
118    res
119}
120
121/// Whether the type of `id` declares `status`.
122fn has_status(prj: &Project, id: &str, status: &str) -> bool {
123    prj.pcfg
124        .type_name_for_id(id)
125        .map(|n| prj.pcfg.types[n].statuses.iter().any(|s| s == status))
126        .unwrap_or(false)
127}
128
129/// Apply `f` to the frontmatter of the doc with `id`, writing it back (and,
130/// when `relocate`, updating its canonical path) only if `f` reports a change.
131/// Errors if `id` is not found.
132fn edit_doc<F>(prj: &Project, store: &mut Store, id: &str, relocate: bool, f: F) -> Result<()>
133where
134    F: FnOnce(&mut Frontmatter) -> bool,
135{
136    let dkey = store.dkey_of(id)?;
137    let mut doc = store.doc(dkey)?;
138    if f(&mut doc.frontmatter) {
139        store.put_doc(&prj.pcfg, Some(dkey), &doc)?;
140        if relocate {
141            store.set_canonical_path(&prj.pcfg, dkey)?;
142        }
143    }
144    Ok(())
145}