1pub mod cli;
10pub mod commands;
11pub mod error;
12pub mod output;
13pub(crate) mod redact;
14pub mod store;
15
16use crate::error::{AppError, AppResult};
17use jiff::{SignedDuration, Timestamp, Unit};
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use std::fmt::Write as _;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
23#[serde(rename_all = "lowercase")]
24pub enum Impact {
25 Low,
26 Material,
27 Blocking,
28}
29
30impl Impact {
31 pub fn as_str(self) -> &'static str {
32 match self {
33 Self::Low => "low",
34 Self::Material => "material",
35 Self::Blocking => "blocking",
36 }
37 }
38
39 pub fn rank(self) -> u8 {
40 match self {
41 Self::Low => 0,
42 Self::Material => 1,
43 Self::Blocking => 2,
44 }
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
51#[serde(rename_all = "lowercase")]
52pub enum Disposition {
53 Fixed,
54 Promoted,
55 Accepted,
56 Invalid,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum Pattern {
65 RecurrentFriction,
66 FailedIntervention,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
72#[serde(rename_all = "lowercase")]
73pub enum ArtifactType {
74 Doc,
75 Skill,
76 Guard,
77 Test,
78 Tool,
79 Process,
80}
81
82impl ArtifactType {
83 pub fn as_str(self) -> &'static str {
84 match self {
85 Self::Doc => "doc",
86 Self::Skill => "skill",
87 Self::Guard => "guard",
88 Self::Test => "test",
89 Self::Tool => "tool",
90 Self::Process => "process",
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct Artifact {
98 #[serde(rename = "type")]
99 pub kind: ArtifactType,
100 #[serde(rename = "ref")]
101 pub r#ref: String,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct Origin {
110 #[serde(rename = "type")]
111 pub kind: String,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub provider: Option<String>,
114 #[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
115 pub r#ref: Option<String>,
116}
117
118impl Origin {
119 pub fn agent() -> Self {
121 Self {
122 kind: "agent".into(),
123 provider: None,
124 r#ref: None,
125 }
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct Evidence {
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub cmd: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub exit: Option<i32>,
135 #[serde(skip_serializing_if = "Option::is_none")]
136 pub stderr: Option<String>,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub note: Option<String>,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(tag = "kind", rename_all = "lowercase")]
143pub enum LogEvent {
144 Cut {
145 id: String,
146 ts: String,
147 agent: String,
148 text: String,
149 tags: Vec<String>,
150 impact: Impact,
151 cwd: String,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 origin: Option<Origin>,
154 #[serde(skip_serializing_if = "Option::is_none")]
155 evidence: Option<Evidence>,
156 },
157 Dogear {
158 id: String,
159 ts: String,
160 agent: String,
161 text: String,
162 tags: Vec<String>,
163 #[serde(skip_serializing_if = "Option::is_none")]
164 evidence: Option<String>,
165 cwd: String,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 origin: Option<Origin>,
168 },
169 Resolve {
170 id: String,
171 ts: String,
172 agent: String,
173 note: Option<String>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 task: Option<String>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pr: Option<String>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 commit: Option<String>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
181 url: Option<String>,
182 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
183 dropped: bool,
184 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
185 amend: bool,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 disposition: Option<Disposition>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 disposition_ts: Option<String>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 promotion: Option<String>,
192 },
193 Promotion {
194 id: String,
195 ts: String,
196 agent: String,
197 sources: Vec<String>,
198 artifact: Artifact,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 note: Option<String>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 origin: Option<Origin>,
203 cwd: String,
204 },
205 #[serde(other)]
206 Unknown,
207}
208
209impl LogEvent {
210 pub fn id(&self) -> Option<&str> {
211 match self {
212 Self::Cut { id, .. }
213 | Self::Dogear { id, .. }
214 | Self::Resolve { id, .. }
215 | Self::Promotion { id, .. } => Some(id),
216 Self::Unknown => None,
217 }
218 }
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222pub struct Resolution {
223 pub ts: String,
224 pub agent: String,
225 pub note: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub task: Option<String>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub pr: Option<String>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub commit: Option<String>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub url: Option<String>,
234 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
235 pub dropped: bool,
236 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
237 pub amended: bool,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub disposition: Option<Disposition>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub disposition_ts: Option<String>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub promotion: Option<String>,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct ListItem {
250 pub kind: String,
251 pub id: String,
252 pub ts: String,
253 pub agent: String,
254 pub text: String,
255 pub tags: Vec<String>,
256 #[serde(skip_serializing_if = "Option::is_none")]
257 pub impact: Option<Impact>,
258 pub cwd: String,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub origin: Option<Origin>,
261 #[serde(skip_serializing_if = "Option::is_none")]
262 pub evidence: Option<serde_json::Value>,
263 pub status: ItemStatus,
264 #[serde(skip_serializing_if = "Option::is_none")]
265 pub resolution: Option<Resolution>,
266}
267
268impl ListItem {
269 pub(crate) fn from_record(event: LogEvent, resolution: Option<Resolution>) -> Self {
270 let status = if resolution.is_some() {
271 ItemStatus::Resolved
272 } else {
273 ItemStatus::Open
274 };
275 match event {
276 LogEvent::Cut {
277 id,
278 ts,
279 agent,
280 text,
281 tags,
282 impact,
283 cwd,
284 origin,
285 evidence,
286 } => Self {
287 kind: "cut".into(),
288 id,
289 ts,
290 agent,
291 text,
292 tags,
293 impact: Some(impact),
294 cwd,
295 origin,
296 evidence: evidence
297 .map(|evidence| serde_json::to_value(evidence).expect("evidence serializes")),
298 status,
299 resolution,
300 },
301 LogEvent::Dogear {
302 id,
303 ts,
304 agent,
305 text,
306 tags,
307 evidence,
308 cwd,
309 origin,
310 } => Self {
311 kind: "dogear".into(),
312 id,
313 ts,
314 agent,
315 text,
316 tags,
317 impact: None,
318 cwd,
319 origin,
320 evidence: evidence.map(serde_json::Value::String),
321 status,
322 resolution,
323 },
324 LogEvent::Resolve { .. } | LogEvent::Promotion { .. } | LogEvent::Unknown => {
325 unreachable!("folded list items are cut or dogear")
326 }
327 }
328 }
329}
330
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335pub struct PromotionItem {
336 pub kind: String,
337 pub id: String,
338 pub ts: String,
339 pub agent: String,
340 pub sources: Vec<String>,
341 pub artifact: Artifact,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub note: Option<String>,
344 pub cwd: String,
345 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub origin: Option<Origin>,
347}
348
349impl PromotionItem {
350 pub(crate) fn from_record(event: LogEvent) -> Self {
351 let LogEvent::Promotion {
352 id,
353 ts,
354 agent,
355 sources,
356 artifact,
357 note,
358 origin,
359 cwd,
360 } = event
361 else {
362 unreachable!("only promotion records become promotion items")
363 };
364 Self {
365 kind: "promotion".into(),
366 id,
367 ts,
368 agent,
369 sources,
370 artifact,
371 note,
372 cwd,
373 origin,
374 }
375 }
376}
377
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
379#[serde(rename_all = "lowercase")]
380pub enum ItemStatus {
381 Open,
382 Resolved,
383}
384
385pub(crate) fn is_bl_id(id: &str) -> bool {
389 id.get(..3)
390 .is_some_and(|prefix| prefix.eq_ignore_ascii_case("bl_"))
391}
392
393pub fn effective_now() -> AppResult<Timestamp> {
394 let timestamp = match std::env::var("BLOTTER_NOW") {
395 Ok(value) if !value.is_empty() => value.parse::<Timestamp>().map_err(|_| {
396 AppError::config(
397 "BLOTTER_NOW must be a full RFC3339 timestamp",
398 "Set BLOTTER_NOW to a value like 2026-07-09T18:30:00Z or unset it.",
399 )
400 })?,
401 Ok(_) | Err(std::env::VarError::NotPresent) => Timestamp::now(),
402 Err(std::env::VarError::NotUnicode(_)) => {
403 return Err(AppError::config(
404 "BLOTTER_NOW is not valid UTF-8",
405 "Set BLOTTER_NOW to a full RFC3339 timestamp or unset it.",
406 ));
407 }
408 };
409 timestamp
410 .round(Unit::Millisecond)
411 .map_err(|error| AppError::internal(error.to_string()))
412}
413
414pub fn format_timestamp(timestamp: Timestamp) -> String {
415 format!("{timestamp:.3}")
416}
417
418pub fn parse_since(value: &str, now: Timestamp) -> AppResult<Timestamp> {
419 parse_cutoff("--since", value, now)
420}
421
422pub fn parse_before(value: &str, now: Timestamp) -> AppResult<Timestamp> {
423 parse_cutoff("--before", value, now)
424}
425
426fn parse_cutoff(flag_name: &str, value: &str, now: Timestamp) -> AppResult<Timestamp> {
427 let is_since = flag_name == "--since";
428 let relative_suggested_fix = || {
429 if is_since {
430 "Use a full RFC3339 timestamp, Nd, or Nh.".to_owned()
431 } else {
432 format!("Use {flag_name} with a full RFC3339 timestamp, Nd, or Nh.")
433 }
434 };
435 let smaller_duration_suggested_fix = || {
436 if is_since {
437 "Use a smaller Nd or Nh duration.".to_owned()
438 } else {
439 format!("Use a smaller relative value for {flag_name}.")
440 }
441 };
442 let absolute_suggested_fix = || {
443 if is_since {
444 "Use a full RFC3339 timestamp such as 2026-07-09T18:30:00Z, or a relative value such as 7d or 12h.".to_owned()
445 } else {
446 format!(
447 "Use {flag_name} with a full RFC3339 timestamp such as 2026-07-09T18:30:00Z, or a relative value such as 7d or 12h."
448 )
449 }
450 };
451 if let Some((number, unit)) = value.split_at_checked(value.len().saturating_sub(1))
452 && !number.is_empty()
453 && number.bytes().all(|byte| byte.is_ascii_digit())
454 && matches!(unit, "d" | "h")
455 {
456 let amount = number.parse::<i64>().map_err(|_| {
457 AppError::invalid_argument(
458 format!("invalid {flag_name} value '{value}'"),
459 relative_suggested_fix(),
460 )
461 })?;
462 let duration = if unit == "d" {
463 amount.checked_mul(24)
464 } else {
465 Some(amount)
466 }
467 .and_then(SignedDuration::try_from_hours)
468 .ok_or_else(|| {
469 AppError::invalid_argument(
470 format!("{flag_name} value '{value}' is too large"),
471 smaller_duration_suggested_fix(),
472 )
473 })?;
474 return now.checked_sub(duration).map_err(|_| {
475 AppError::invalid_argument(
476 format!("{flag_name} value '{value}' is outside the supported range"),
477 smaller_duration_suggested_fix(),
478 )
479 });
480 }
481
482 value.parse::<Timestamp>().map_err(|_| {
483 AppError::invalid_argument(
484 format!("invalid {flag_name} value '{value}'"),
485 absolute_suggested_fix(),
486 )
487 })
488}
489
490pub fn compute_id(ts: &str, agent: &str, text: &str, impact: Impact, tags: &[String]) -> String {
491 let mut tags = tags.to_vec();
492 tags.sort();
493 tags.dedup();
494 let count = tags.len().to_string();
495 let mut fields: Vec<&str> = vec![
496 "bl2",
497 "cut",
498 ts,
499 agent,
500 text,
501 impact.as_str(),
502 count.as_str(),
503 ];
504 fields.extend(tags.iter().map(String::as_str));
505 compute_id_fields_bytes(&fields, 10)
506}
507
508pub fn compute_dogear_id(ts: &str, agent: &str, text: &str, tags: &[String]) -> String {
509 let mut tags = tags.to_vec();
510 tags.sort();
511 tags.dedup();
512 let count = tags.len().to_string();
513 let mut fields: Vec<&str> = vec!["bl2", "dogear", ts, agent, text, count.as_str()];
518 fields.extend(tags.iter().map(String::as_str));
519 compute_id_fields_bytes(&fields, 10)
520}
521
522pub fn compute_promotion_id(
527 ts: &str,
528 agent: &str,
529 sources: &[String],
530 artifact_type: &str,
531 artifact_ref: &str,
532) -> String {
533 let sources = normalized(sources);
534 let count = sources.len().to_string();
535 let mut fields: Vec<&str> = vec!["bl2", "promotion", ts, agent, count.as_str()];
536 fields.extend(sources.iter().map(String::as_str));
537 fields.push(artifact_type);
538 fields.push(artifact_ref);
539 compute_id_fields_bytes(&fields, 10)
540}
541
542pub fn normalized(values: &[String]) -> Vec<String> {
545 let mut values = values.to_vec();
546 values.sort();
547 values.dedup();
548 values
549}
550
551fn compute_id_fields_bytes(fields: &[&str], bytes: usize) -> String {
552 let mut hash = Sha256::new();
553 for field in fields {
554 hash.update((field.len() as u32).to_le_bytes());
555 hash.update(field.as_bytes());
556 }
557 let digest = hash.finalize();
558 let mut id = String::with_capacity(3 + bytes * 2);
559 id.push_str("bl_");
560 for byte in &digest[..bytes] {
561 write!(&mut id, "{byte:02x}").expect("writing to a String cannot fail");
562 }
563 id
564}
565
566pub fn resolve_agent(flag: Option<String>) -> AppResult<(String, &'static str)> {
567 if let Some(agent) = flag.filter(|value| !value.is_empty()) {
568 return Ok((agent, "flag"));
569 }
570 match std::env::var("BLOTTER_AGENT") {
571 Ok(agent) if !agent.is_empty() => return Ok((agent, "env")),
572 Ok(_) | Err(std::env::VarError::NotPresent) => {}
576 Err(std::env::VarError::NotUnicode(_)) => {
577 return Err(AppError::config(
578 "BLOTTER_AGENT is not valid UTF-8",
579 "Set BLOTTER_AGENT to a UTF-8 agent name or unset it.",
580 ));
581 }
582 }
583 if std::env::var_os("CLAUDECODE").is_some() {
584 return Ok(("claude-code".into(), "detected"));
585 }
586 if std::env::vars_os().any(|(key, _)| key.to_string_lossy().starts_with("CODEX_")) {
587 return Ok(("codex".into(), "detected"));
588 }
589 if std::env::vars_os().any(|(key, _)| key.to_string_lossy().starts_with("CURSOR_")) {
590 return Ok(("cursor".into(), "detected"));
591 }
592 Ok(("unknown".into(), "default"))
593}
594
595pub(crate) fn resolve_agent_checked(
596 flag: Option<String>,
597 reject_resolved_whitespace: bool,
598) -> AppResult<(String, &'static str)> {
599 if flag.as_deref().is_some_and(|agent| agent.trim().is_empty()) {
600 return Err(AppError::invalid_input(
601 "agent name cannot be empty or whitespace-only",
602 "Pass a non-empty --agent NAME or omit the flag.",
603 ));
604 }
605 let (agent, source) = resolve_agent(flag)?;
606 if reject_resolved_whitespace && agent.trim().is_empty() {
607 return Err(AppError::invalid_input(
608 "agent name cannot be whitespace-only",
609 "Pass a non-empty --agent NAME or set BLOTTER_AGENT.",
610 ));
611 }
612 Ok((agent, source))
613}