1pub mod cli;
2pub mod commands;
3pub mod error;
4pub mod output;
5pub mod store;
6
7use crate::error::{AppError, AppResult};
8use jiff::{SignedDuration, Timestamp, Unit};
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11use std::fmt::Write as _;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
14#[serde(rename_all = "lowercase")]
15pub enum Severity {
16 Minor,
17 Major,
18 Blocker,
19}
20
21impl Severity {
22 pub fn as_str(self) -> &'static str {
23 match self {
24 Self::Minor => "minor",
25 Self::Major => "major",
26 Self::Blocker => "blocker",
27 }
28 }
29
30 pub fn rank(self) -> u8 {
31 match self {
32 Self::Minor => 0,
33 Self::Major => 1,
34 Self::Blocker => 2,
35 }
36 }
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct Evidence {
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub cmd: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub exit: Option<i32>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub stderr: Option<String>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub note: Option<String>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(tag = "kind", rename_all = "lowercase")]
53pub enum LogEvent {
54 Cut {
55 id: String,
56 ts: String,
57 agent: String,
58 text: String,
59 tags: Vec<String>,
60 severity: Severity,
61 cwd: String,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 source: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 evidence: Option<Evidence>,
66 },
67 Dogear {
68 id: String,
69 ts: String,
70 agent: String,
71 text: String,
72 tags: Vec<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 evidence: Option<String>,
75 cwd: String,
76 },
77 Resolve {
78 id: String,
79 ts: String,
80 agent: String,
81 note: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 task: Option<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pr: Option<String>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 commit: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 url: Option<String>,
90 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
91 dropped: bool,
92 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
93 amend: bool,
94 },
95 #[serde(other)]
96 Unknown,
97}
98
99impl LogEvent {
100 pub fn id(&self) -> Option<&str> {
101 match self {
102 Self::Cut { id, .. } | Self::Dogear { id, .. } | Self::Resolve { id, .. } => Some(id),
103 Self::Unknown => None,
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct Resolution {
110 pub ts: String,
111 pub agent: String,
112 pub note: Option<String>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub task: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub pr: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub commit: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub url: Option<String>,
121 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
122 pub dropped: bool,
123 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
124 pub amended: bool,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct ListItem {
129 pub kind: String,
130 pub id: String,
131 pub ts: String,
132 pub agent: String,
133 pub text: String,
134 pub tags: Vec<String>,
135 #[serde(skip_serializing_if = "Option::is_none")]
136 pub severity: Option<Severity>,
137 pub cwd: String,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub source: Option<String>,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub evidence: Option<serde_json::Value>,
142 pub status: ItemStatus,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub resolution: Option<Resolution>,
145}
146
147impl ListItem {
148 pub(crate) fn from_record(event: LogEvent, resolution: Option<Resolution>) -> Self {
149 let status = if resolution.is_some() {
150 ItemStatus::Resolved
151 } else {
152 ItemStatus::Open
153 };
154 match event {
155 LogEvent::Cut {
156 id,
157 ts,
158 agent,
159 text,
160 tags,
161 severity,
162 cwd,
163 source,
164 evidence,
165 } => Self {
166 kind: "cut".into(),
167 id,
168 ts,
169 agent,
170 text,
171 tags,
172 severity: Some(severity),
173 cwd,
174 source,
175 evidence: evidence
176 .map(|evidence| serde_json::to_value(evidence).expect("evidence serializes")),
177 status,
178 resolution,
179 },
180 LogEvent::Dogear {
181 id,
182 ts,
183 agent,
184 text,
185 tags,
186 evidence,
187 cwd,
188 } => Self {
189 kind: "dogear".into(),
190 id,
191 ts,
192 agent,
193 text,
194 tags,
195 severity: None,
196 cwd,
197 source: None,
198 evidence: evidence.map(serde_json::Value::String),
199 status,
200 resolution,
201 },
202 LogEvent::Resolve { .. } | LogEvent::Unknown => {
203 unreachable!("folded records are cut or dogear")
204 }
205 }
206 }
207}
208
209pub fn is_auto_capture(tags: &[String]) -> bool {
210 tags.iter().any(|tag| tag == "auto")
211}
212
213pub(crate) fn partition_auto_captures(
214 items: Vec<ListItem>,
215 include_auto: bool,
216) -> (Vec<ListItem>, Vec<ListItem>) {
217 if include_auto {
218 (items, Vec::new())
219 } else {
220 items
221 .into_iter()
222 .partition(|item| !is_auto_capture(&item.tags))
223 }
224}
225
226pub(crate) fn auto_capture_warning(count: usize) -> String {
227 let noun = if count == 1 { "record" } else { "records" };
228 format!("{count} auto-captured {noun} hidden; use --include-auto to include them")
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(rename_all = "lowercase")]
233pub enum ItemStatus {
234 Open,
235 Resolved,
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239pub(crate) enum IdNamespace {
240 Bl,
241 Pc,
242}
243
244pub(crate) fn id_namespace(id: &str) -> Option<IdNamespace> {
245 match id.get(..3) {
246 Some(prefix) if prefix.eq_ignore_ascii_case("bl_") => Some(IdNamespace::Bl),
247 Some(prefix) if prefix.eq_ignore_ascii_case("pc_") => Some(IdNamespace::Pc),
248 _ => None,
249 }
250}
251
252pub fn effective_now() -> AppResult<Timestamp> {
253 let timestamp = match std::env::var("BLOTTER_NOW") {
254 Ok(value) if !value.is_empty() => value.parse::<Timestamp>().map_err(|_| {
255 AppError::config(
256 "BLOTTER_NOW must be a full RFC3339 timestamp",
257 "Set BLOTTER_NOW to a value like 2026-07-09T18:30:00Z or unset it.",
258 )
259 })?,
260 Ok(_) | Err(std::env::VarError::NotPresent) => Timestamp::now(),
261 Err(std::env::VarError::NotUnicode(_)) => {
262 return Err(AppError::config(
263 "BLOTTER_NOW is not valid UTF-8",
264 "Set BLOTTER_NOW to a full RFC3339 timestamp or unset it.",
265 ));
266 }
267 };
268 timestamp
269 .round(Unit::Millisecond)
270 .map_err(|error| AppError::internal(error.to_string()))
271}
272
273pub fn format_timestamp(timestamp: Timestamp) -> String {
274 format!("{timestamp:.3}")
275}
276
277pub fn parse_since(value: &str, now: Timestamp) -> AppResult<Timestamp> {
278 parse_cutoff("--since", value, now)
279}
280
281pub fn parse_before(value: &str, now: Timestamp) -> AppResult<Timestamp> {
282 parse_cutoff("--before", value, now)
283}
284
285fn parse_cutoff(flag_name: &str, value: &str, now: Timestamp) -> AppResult<Timestamp> {
286 let is_since = flag_name == "--since";
287 let relative_suggested_fix = || {
288 if is_since {
289 "Use a full RFC3339 timestamp, Nd, or Nh.".to_owned()
290 } else {
291 format!("Use {flag_name} with a full RFC3339 timestamp, Nd, or Nh.")
292 }
293 };
294 let smaller_duration_suggested_fix = || {
295 if is_since {
296 "Use a smaller Nd or Nh duration.".to_owned()
297 } else {
298 format!("Use a smaller relative value for {flag_name}.")
299 }
300 };
301 let absolute_suggested_fix = || {
302 if is_since {
303 "Use a full RFC3339 timestamp such as 2026-07-09T18:30:00Z, or a relative value such as 7d or 12h.".to_owned()
304 } else {
305 format!(
306 "Use {flag_name} with a full RFC3339 timestamp such as 2026-07-09T18:30:00Z, or a relative value such as 7d or 12h."
307 )
308 }
309 };
310 if let Some((number, unit)) = value.split_at_checked(value.len().saturating_sub(1))
311 && !number.is_empty()
312 && number.bytes().all(|byte| byte.is_ascii_digit())
313 && matches!(unit, "d" | "h")
314 {
315 let amount = number.parse::<i64>().map_err(|_| {
316 AppError::invalid_argument(
317 format!("invalid {flag_name} value '{value}'"),
318 relative_suggested_fix(),
319 )
320 })?;
321 let duration = if unit == "d" {
322 amount.checked_mul(24)
323 } else {
324 Some(amount)
325 }
326 .and_then(SignedDuration::try_from_hours)
327 .ok_or_else(|| {
328 AppError::invalid_argument(
329 format!("{flag_name} value '{value}' is too large"),
330 smaller_duration_suggested_fix(),
331 )
332 })?;
333 return now.checked_sub(duration).map_err(|_| {
334 AppError::invalid_argument(
335 format!("{flag_name} value '{value}' is outside the supported range"),
336 smaller_duration_suggested_fix(),
337 )
338 });
339 }
340
341 value.parse::<Timestamp>().map_err(|_| {
342 AppError::invalid_argument(
343 format!("invalid {flag_name} value '{value}'"),
344 absolute_suggested_fix(),
345 )
346 })
347}
348
349pub fn compute_id(
350 ts: &str,
351 agent: &str,
352 text: &str,
353 severity: Severity,
354 tags: &[String],
355) -> String {
356 let mut tags = tags.to_vec();
357 tags.sort();
358 tags.dedup();
359 let count = tags.len().to_string();
360 let mut fields: Vec<&str> = vec![
361 "bl1",
362 "cut",
363 ts,
364 agent,
365 text,
366 severity.as_str(),
367 count.as_str(),
368 ];
369 fields.extend(tags.iter().map(String::as_str));
370 compute_id_fields_bytes(&fields, 6)
371}
372
373pub fn compute_dogear_id(ts: &str, agent: &str, text: &str, tags: &[String]) -> String {
374 let mut tags = tags.to_vec();
375 tags.sort();
376 tags.dedup();
377 let count = tags.len().to_string();
378 let mut fields: Vec<&str> = vec!["bl1", "dogear", ts, agent, text, count.as_str()];
383 fields.extend(tags.iter().map(String::as_str));
384 compute_id_fields_bytes(&fields, 10)
385}
386
387fn compute_id_fields_bytes(fields: &[&str], bytes: usize) -> String {
388 let mut hash = Sha256::new();
389 for field in fields {
390 hash.update((field.len() as u32).to_le_bytes());
391 hash.update(field.as_bytes());
392 }
393 let digest = hash.finalize();
394 let mut id = String::with_capacity(3 + bytes * 2);
395 id.push_str("bl_");
396 for byte in &digest[..bytes] {
397 write!(&mut id, "{byte:02x}").expect("writing to a String cannot fail");
398 }
399 id
400}
401
402pub fn resolve_agent(flag: Option<String>) -> (String, &'static str) {
403 if let Some(agent) = flag.filter(|value| !value.is_empty()) {
404 return (agent, "flag");
405 }
406 if let Ok(agent) = std::env::var("BLOTTER_AGENT")
407 && !agent.is_empty()
408 {
409 return (agent, "env");
410 }
411 if std::env::var_os("CLAUDECODE").is_some() {
412 return ("claude-code".into(), "detected");
413 }
414 if std::env::vars_os().any(|(key, _)| key.to_string_lossy().starts_with("CODEX_")) {
415 return ("codex".into(), "detected");
416 }
417 if std::env::vars_os().any(|(key, _)| key.to_string_lossy().starts_with("CURSOR_")) {
418 return ("cursor".into(), "detected");
419 }
420 ("unknown".into(), "default")
421}
422
423pub(crate) fn resolve_agent_checked(
424 flag: Option<String>,
425 reject_resolved_whitespace: bool,
426) -> AppResult<(String, &'static str)> {
427 if flag.as_deref().is_some_and(|agent| agent.trim().is_empty()) {
428 return Err(AppError::invalid_input(
429 "agent name cannot be empty or whitespace-only",
430 "Pass a non-empty --agent NAME or omit the flag.",
431 ));
432 }
433 let (agent, source) = resolve_agent(flag);
434 if reject_resolved_whitespace && agent.trim().is_empty() {
435 return Err(AppError::invalid_input(
436 "agent name cannot be whitespace-only",
437 "Pass a non-empty --agent NAME or set BLOTTER_AGENT.",
438 ));
439 }
440 Ok((agent, source))
441}