Skip to main content

opys_engine/commands/
mod.rs

1//! Subcommand implementations. Each `run` takes the invocation [`Ctx`] plus
2//! its parsed arguments.
3
4pub mod agent_rules;
5pub mod block;
6pub mod cleanup;
7pub mod close;
8pub mod config;
9#[cfg(feature = "history")]
10pub mod history;
11pub mod import;
12pub mod init;
13pub mod list;
14pub mod new;
15pub mod query;
16pub mod renumber;
17pub mod retire;
18pub mod set_status;
19pub mod show;
20pub mod stats;
21pub mod sync;
22pub mod tag;
23pub mod tags;
24pub mod verify;
25
26use serde_norway::Value;
27use time::{
28    format_description::well_known::Rfc3339, format_description::FormatItem,
29    macros::format_description, OffsetDateTime,
30};
31
32use crate::error::{usage, Result};
33use crate::frontmatter::Frontmatter;
34use crate::project::Project;
35use crate::Ctx;
36
37const ISO_DATE: &[FormatItem<'static>] = format_description!("[year]-[month]-[day]");
38
39/// Today's date as `YYYY-MM-DD` (local time, falling back to UTC).
40pub fn today() -> String {
41    let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
42    now.format(&ISO_DATE).expect("ISO date formatting")
43}
44
45/// The current instant as an RFC3339 datetime, second precision (local time,
46/// falling back to UTC). Honors the `OPYS_NOW` environment override so tests can
47/// pin a deterministic timestamp; the override is returned verbatim.
48pub fn now_rfc3339() -> String {
49    if let Ok(s) = std::env::var("OPYS_NOW") {
50        if !s.is_empty() {
51            return s;
52        }
53    }
54    let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
55    let now = now.replace_nanosecond(0).unwrap_or(now);
56    now.format(&Rfc3339).expect("RFC3339 formatting")
57}
58
59/// Stamp the auto-maintained timestamp fields on a user-initiated write: always
60/// refresh `updated`, and set `created` once (only if absent). Reconcile/linkify
61/// housekeeping must NOT call this — only meaningful user edits bump `updated`.
62pub fn touch(fm: &mut Frontmatter) {
63    let now = now_rfc3339();
64    if !fm.contains_key("created") {
65        fm.set_str("created", now.clone());
66    }
67    fm.set_str("updated", now);
68}
69
70/// Split a comma-separated argument, trimming and dropping empties.
71pub fn split_csv(s: &str) -> Vec<String> {
72    s.split(',')
73        .map(str::trim)
74        .filter(|t| !t.is_empty())
75        .map(str::to_string)
76        .collect()
77}
78
79/// The key portion of a tag, for filtering by key: the text before the first
80/// `:` or `=`. A bare tag is its own key (`osc` → `osc`), a namespaced or keyed
81/// tag exposes its head (`area:parsing` / `priority=high` → `area` / `priority`).
82pub fn tag_key(tag: &str) -> &str {
83    tag.split([':', '=']).next().unwrap_or(tag)
84}
85
86/// Parse the id argument of a bulk command into a deduplicated, order-preserving
87/// list. Bulk is opt-in via an explicit comma-separated list (`FEAT-1,FEAT-2`):
88/// a single id is just a one-element list. Space-separated ids are *not* accepted
89/// as positionals — clap rejects the extra positional, so shell word-splitting
90/// (e.g. an unintended `$(opys list --format ids)` expansion) fails loudly
91/// instead of silently widening a destructive batch. The literal `-` instead
92/// reads the list from stdin, where comma, space, and newline separation are all
93/// accepted (so `opys list --format ids | opys close -` works directly). Errors
94/// if no ids remain after trimming.
95pub fn expand_ids(arg: &str) -> Result<Vec<String>> {
96    let ids = if arg.trim() == "-" {
97        read_ids_from_stdin()?
98    } else {
99        split_csv(arg)
100    };
101    let mut out: Vec<String> = Vec::new();
102    for id in ids {
103        if !out.contains(&id) {
104            out.push(id);
105        }
106    }
107    if out.is_empty() {
108        return Err(usage("expected at least one id"));
109    }
110    Ok(out)
111}
112
113/// Read ids from stdin for the `-` argument, accepting any mix of comma, space,
114/// and newline separation.
115fn read_ids_from_stdin() -> Result<Vec<String>> {
116    use std::io::Read;
117    let mut buf = String::new();
118    std::io::stdin().read_to_string(&mut buf)?;
119    Ok(buf
120        .split(|c: char| c == ',' || c.is_whitespace())
121        .filter(|t| !t.is_empty())
122        .map(str::to_string)
123        .collect())
124}
125
126/// Run `f` against each id of a bulk command. A single id behaves exactly like
127/// the original one-shot command (its error propagates verbatim). With several
128/// ids it is best-effort: every id is attempted, each failure is reported, and
129/// an aggregate error is returned if any failed (so the exit code is nonzero)
130/// while the successful writes stand.
131pub fn for_each_id<F>(ids: &[String], mut f: F) -> Result<()>
132where
133    F: FnMut(&str) -> Result<()>,
134{
135    if let [only] = ids {
136        return f(only);
137    }
138    let mut failed: Vec<&str> = Vec::new();
139    for id in ids {
140        if let Err(e) = f(id) {
141            eprintln!("error: {id}: {e}");
142            failed.push(id);
143        }
144    }
145    if !failed.is_empty() {
146        return Err(usage(format!(
147            "{} of {} ids failed: {}",
148            failed.len(),
149            ids.len(),
150            failed.join(", ")
151        )));
152    }
153    Ok(())
154}
155
156/// Parse repeatable `--field key=value` list filters into `(key, value)` pairs.
157pub fn parse_field_filters(args: &[String]) -> Result<Vec<(String, String)>> {
158    args.iter()
159        .map(|a| {
160            a.split_once('=')
161                .map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
162                .ok_or_else(|| usage(format!("--field expects key=value, got {a:?}")))
163        })
164        .collect()
165}
166
167/// Bare scalar string form of a YAML value, or `None` if it is composite.
168/// Used to compare custom-field values against string filters predictably
169/// across string/int/bool/enum types (and list elements).
170fn scalar_str(v: &Value) -> Option<String> {
171    match v {
172        Value::Bool(b) => Some(b.to_string()),
173        Value::Number(n) => Some(n.to_string()),
174        Value::String(s) => Some(s.clone()),
175        _ => None,
176    }
177}
178
179/// Whether frontmatter `fm` satisfies every `(key, value)` filter (AND). A
180/// filter matches when the field is a scalar equal to the value, or a sequence
181/// containing an element equal to it; a missing or composite field never matches.
182pub fn field_matches(fm: &Frontmatter, filters: &[(String, String)]) -> bool {
183    filters.iter().all(|(key, want)| match fm.get(key) {
184        Some(Value::Sequence(seq)) => seq.iter().filter_map(scalar_str).any(|s| &s == want),
185        Some(v) => scalar_str(v).as_ref() == Some(want),
186        None => false,
187    })
188}
189
190/// Reconcile references, linkify bodies, and relocate docs to their canonical
191/// layout paths after a mutating command, unless `--no-sync`. Best-effort: a
192/// parse error elsewhere is reported but does not fail the mutation that already
193/// succeeded.
194pub fn maybe_sync(ctx: &Ctx, prj: &Project) {
195    if ctx.no_sync {
196        return;
197    }
198    if sync::run(prj, ctx.backend.as_ref()).is_err() {
199        eprintln!("note: skipped sync (run `opys verify` to find the problem)");
200    }
201}
202
203/// Like [`maybe_sync`] but returns the result instead of printing — for callers
204/// (e.g. the TUI) that must not write to stdout/stderr and want to surface a
205/// sync failure themselves. Returns the number of documents synced (0 when
206/// `--no-sync`).
207pub fn sync_quiet(ctx: &Ctx, prj: &Project) -> Result<usize> {
208    if ctx.no_sync {
209        return Ok(0);
210    }
211    sync::run(prj, ctx.backend.as_ref())
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use time::{format_description::well_known::Rfc3339, OffsetDateTime};
218
219    #[test]
220    fn now_rfc3339_is_parseable() {
221        let s = now_rfc3339();
222        assert!(OffsetDateTime::parse(&s, &Rfc3339).is_ok(), "got {s:?}");
223    }
224
225    #[test]
226    fn touch_sets_created_when_absent_equal_to_updated() {
227        let mut fm = Frontmatter::new();
228        touch(&mut fm);
229        let created = fm.get_str("created").map(str::to_string);
230        let updated = fm.get_str("updated").map(str::to_string);
231        assert!(created.is_some());
232        assert_eq!(created, updated);
233    }
234
235    #[test]
236    fn touch_preserves_existing_created_but_refreshes_updated() {
237        let mut fm = Frontmatter::new();
238        fm.set_str("created", "2020-01-01T00:00:00Z");
239        touch(&mut fm);
240        assert_eq!(fm.get_str("created"), Some("2020-01-01T00:00:00Z"));
241        assert!(fm.get_str("updated").is_some());
242        assert_ne!(fm.get_str("updated"), Some("2020-01-01T00:00:00Z"));
243    }
244}