opys_engine/commands/
mod.rs1pub 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
39pub 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
45pub 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
59pub 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
70pub 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
79pub fn tag_key(tag: &str) -> &str {
83 tag.split([':', '=']).next().unwrap_or(tag)
84}
85
86pub 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
113fn 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
126pub 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
156pub 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
167fn 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
179pub 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
190pub 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
203pub 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}