blotter-cli 0.15.0

A tiny CLI for AI agents to log the cuts they hit during work.
Documentation
use crate::cli::VerifyArgs;
use crate::commands::triage::{self, Candidate};
use crate::error::{AppError, AppResult};
use crate::output::{self, Meta};
use crate::store;
use crate::{ItemStatus, ListItem};
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

#[derive(Debug, Serialize, Deserialize)]
pub struct VerifyData {
    pub recurrences: Vec<Recurrence>,
    pub count: usize,
    pub scanned: usize,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Recurrence {
    pub resolved_id: String,
    pub resolved_text: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source: Option<String>,
    pub resolution: VerifyResolution,
    pub recurrence_ids: Vec<String>,
    pub count: usize,
    pub first_recurrence_ts: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct VerifyResolution {
    pub ts: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub task: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pr: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub commit: Option<String>,
}

struct ResolvedAnchor {
    candidate: Candidate,
    resolution_timestamp: Timestamp,
}

pub(crate) struct RecurrenceGroup {
    pub(crate) anchor: Candidate,
    pub(crate) members: Vec<Candidate>,
}

pub(crate) struct RecurrenceAnalysis {
    pub(crate) recurrences: Vec<RecurrenceGroup>,
    pub(crate) scanned: usize,
}

struct OrderedRecurrence {
    data: RecurrenceGroup,
    first_recurrence_timestamp: Timestamp,
}

fn is_verify_eligible(item: &ListItem) -> bool {
    if item.kind != "cut" {
        return false;
    }

    match item.status {
        ItemStatus::Open => true,
        ItemStatus::Resolved => {
            let resolution = item
                .resolution
                .as_ref()
                .expect("resolved folded items have a resolution");
            !resolution.dropped && !triage::normalized_title(&item.text).is_empty()
        }
    }
}

pub fn run(args: VerifyArgs, file: Option<PathBuf>, pretty: bool) -> AppResult<i32> {
    let resolved = store::discover(file)?;
    let store::LoadedFold {
        items,
        mut warnings,
    } = store::load_folded(&resolved)?;
    let (items, auto_captures) = crate::partition_auto_captures(items, args.include_auto);
    let hidden = auto_captures
        .iter()
        .filter(|item| is_verify_eligible(item))
        .count();
    if hidden > 0 {
        warnings.push(crate::auto_capture_warning(hidden));
    }

    let data = verify(items);
    let exit = i32::from(!data.recurrences.is_empty());
    let mut meta = Meta::new();
    meta.file = Some(resolved.path.to_string_lossy().into_owned());
    meta.warnings = warnings;
    output::write_success(data, pretty, meta)
        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
    Ok(exit)
}

fn verify(items: Vec<ListItem>) -> VerifyData {
    let analysis = recurrence_groups(items);
    let recurrences: Vec<_> = analysis
        .recurrences
        .iter()
        .map(materialize_recurrence)
        .collect();

    VerifyData {
        count: recurrences.len(),
        recurrences,
        scanned: analysis.scanned,
    }
}

pub(crate) fn recurrence_groups(items: Vec<ListItem>) -> RecurrenceAnalysis {
    let mut open = Vec::new();
    let mut anchors = Vec::new();

    for item in items {
        if !is_verify_eligible(&item) {
            continue;
        }

        let normalized_title = triage::normalized_title(&item.text);
        let candidate = Candidate {
            timestamp: item
                .ts
                .parse()
                .expect("folded items have valid RFC3339 timestamps"),
            tags: item.tags.iter().cloned().collect(),
            tokens: triage::scoring_tokens(&normalized_title),
            normalized_title,
            item,
        };

        match candidate.item.status {
            ItemStatus::Open => open.push(candidate),
            ItemStatus::Resolved => {
                let resolution = candidate
                    .item
                    .resolution
                    .as_ref()
                    .expect("resolved folded items have a resolution");
                anchors.push(ResolvedAnchor {
                    resolution_timestamp: resolution
                        .ts
                        .parse()
                        .expect("folded resolutions have valid RFC3339 timestamps"),
                    candidate,
                });
            }
        }
    }

    open.sort_by(triage::candidate_order);
    let scanned = open.len();
    let frequencies = triage::token_frequencies(
        open.iter()
            .chain(anchors.iter().map(|anchor| &anchor.candidate)),
    );
    let mut recurrences = Vec::new();
    for anchor in anchors {
        let recurring: Vec<_> = open
            .iter()
            .filter(|candidate| {
                candidate.timestamp > anchor.resolution_timestamp
                    && triage::linked(&anchor.candidate, candidate, &frequencies)
            })
            .collect();
        let Some(first) = recurring.first() else {
            continue;
        };
        recurrences.push(OrderedRecurrence {
            first_recurrence_timestamp: first.timestamp,
            data: RecurrenceGroup {
                anchor: anchor.candidate,
                members: recurring.into_iter().cloned().collect(),
            },
        });
    }
    recurrences.sort_by(|left, right| {
        left.first_recurrence_timestamp
            .cmp(&right.first_recurrence_timestamp)
            .then_with(|| left.data.anchor.item.id.cmp(&right.data.anchor.item.id))
    });

    RecurrenceAnalysis {
        recurrences: recurrences
            .into_iter()
            .map(|recurrence| recurrence.data)
            .collect(),
        scanned,
    }
}

fn materialize_recurrence(group: &RecurrenceGroup) -> Recurrence {
    let resolution = group
        .anchor
        .item
        .resolution
        .as_ref()
        .expect("resolved anchors have a resolution");
    let first = group
        .members
        .first()
        .expect("recurrence groups have members");

    Recurrence {
        resolved_id: group.anchor.item.id.clone(),
        resolved_text: group.anchor.item.text.clone(),
        source: group.anchor.item.source.clone(),
        resolution: VerifyResolution {
            ts: resolution.ts.clone(),
            task: resolution.task.clone(),
            pr: resolution.pr.clone(),
            commit: resolution.commit.clone(),
        },
        recurrence_ids: group
            .members
            .iter()
            .map(|candidate| candidate.item.id.clone())
            .collect(),
        count: group.members.len(),
        first_recurrence_ts: first.item.ts.clone(),
    }
}