Skip to main content

blotter/
lib.rs

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    // v1 dogear identity: a version literal and the kind provide domain
379    // separation, and every tag is its own length-prefixed field (TupleHash
380    // style) so tag-set boundaries cannot collide (`["a","b"]` != `["a,b"]`).
381    // 80-bit digest; cut IDs use the matching framed scheme at 48 bits.
382    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}