opys_engine/commands/
close.rs1use 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
15pub 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 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 let ref_targets: HashSet<String> = refs::parse(&closing.frontmatter)
58 .into_iter()
59 .map(|(tid, _)| tid)
60 .collect();
61
62 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
103fn 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}