Skip to main content

opys_engine/commands/
close.rs

1//! `opys close` — finish a document of a type that has a terminal status,
2//! deleting its file and striking every reference to it into a tombstone.
3
4use std::collections::HashSet;
5
6use crate::body;
7use crate::commands::{expand_ids, for_each_id, maybe_sync};
8use crate::error::{usage, Result};
9use crate::frontmatter::Frontmatter;
10use crate::project::Project;
11use crate::project_config::SectionKind;
12use crate::store::{g_i64, IntoParam, Store};
13use crate::{refs, Ctx};
14
15/// Close `id`: delete its file and strike every reference to it. Does not flush/
16/// print/sync — the shared core for the CLI wrapper and the TUI. Striking other
17/// docs' relation maps is housekeeping, so it does not bump their `updated`.
18pub fn core(prj: &Project, store: &mut Store, id: &str, force: bool) -> Result<()> {
19    let tname = prj
20        .pcfg
21        .type_name_for_id(id)
22        .ok_or_else(|| usage(format!("unrecognized id prefix in {id}")))?
23        .to_string();
24    let t = &prj.pcfg.types[&tname];
25    if t.terminal_statuses.is_empty() {
26        return Err(usage(format!(
27            "type '{tname}' has no terminal status, so '{id}' cannot be closed"
28        )));
29    }
30
31    let dkey = store.dkey_of(id)?;
32    let closing = store.doc(dkey)?;
33
34    // Don't close with unfinished work unless forced: any required checklist
35    // section must be fully checked.
36    if !force {
37        for sec in t
38            .sections
39            .iter()
40            .filter(|s| s.required && s.kind == SectionKind::Checklist)
41        {
42            if body::checklist_items(&closing.body, &sec.heading)
43                .iter()
44                .any(|i| !i.checked)
45            {
46                return Err(usage(format!(
47                    "cannot close — unchecked items remain in '## {}' (check them, or pass --force)",
48                    sec.heading
49                )));
50            }
51        }
52    }
53
54    let struck = refs::strike(&closing.title);
55    // Targets this doc references should carry a struck `references` tombstone
56    // even if their reverse link is not yet present.
57    let ref_targets: HashSet<String> = refs::parse(&closing.frontmatter)
58        .into_iter()
59        .map(|(tid, _)| tid)
60        .collect();
61
62    // The docs to update: everything that references the closing doc (any
63    // field), plus every live target it references (for the reverse tombstone).
64    let (_, rows) = store.select(
65        "SELECT DISTINCT dkey FROM relations WHERE ref_id = $1 AND dkey <> $2",
66        vec![id.into_param(), dkey.into_param()],
67    )?;
68    let mut affected: Vec<i64> = rows.iter().filter_map(|r| g_i64(&r[0])).collect();
69    for tid in &ref_targets {
70        if let Some(k) = store.dkey_opt(tid)? {
71            if k != dkey && !affected.contains(&k) {
72                affected.push(k);
73            }
74        }
75    }
76
77    for k in affected {
78        let mut doc = store.doc(k)?;
79        let add_ref = ref_targets.contains(doc.id().unwrap_or(""));
80        if strike_everywhere(&mut doc.frontmatter, id, &struck, add_ref) {
81            store.put_doc(&prj.pcfg, Some(k), &doc)?;
82        }
83    }
84
85    store.delete_doc(dkey)?;
86    Ok(())
87}
88
89pub fn run(ctx: &Ctx, ids: &str, force: bool) -> Result<()> {
90    let prj = ctx.open()?;
91    let ids = expand_ids(ids)?;
92    let (mut store, _) = ctx.load(&prj)?;
93    let res = for_each_id(&ids, |id| {
94        core(&prj, &mut store, id, force)?;
95        println!("closed {id} (deleted; references struck through)");
96        Ok(())
97    });
98    ctx.flush(&prj, store)?;
99    maybe_sync(ctx, &prj);
100    res
101}
102
103/// Strike `id` to a tombstone in every relation map that lists it; for
104/// `references`, optionally add a struck entry when `add_ref` and none is
105/// present. Returns whether anything changed.
106fn strike_everywhere(fm: &mut Frontmatter, id: &str, struck: &str, add_ref: bool) -> bool {
107    let mut changed = false;
108    for field in refs::RELATION_FIELDS {
109        let add = add_ref && field == refs::FIELD;
110        changed |= strike_in_field(fm, field, id, struck, add);
111    }
112    changed
113}
114
115fn strike_in_field(
116    fm: &mut Frontmatter,
117    field: &str,
118    id: &str,
119    struck: &str,
120    add_if_missing: bool,
121) -> bool {
122    let mut entries = refs::parse_in(fm, field);
123    if let Some(e) = entries.iter_mut().find(|(i, _)| i == id) {
124        if e.1 == struck {
125            return false;
126        }
127        e.1 = struck.to_string();
128    } else if add_if_missing {
129        entries.push((id.to_string(), struck.to_string()));
130    } else {
131        return false;
132    }
133    refs::set_in(fm, field, &entries);
134    true
135}