blotter-cli 0.15.0

A tiny CLI for AI agents to log the cuts they hit during work.
Documentation
use crate::cli::ResolveArgs;
use crate::error::{AppError, AppResult};
use crate::output::{self, Meta};
use crate::store;
use crate::{
    IdNamespace, ItemStatus, ListItem, LogEvent, Resolution, format_timestamp, id_namespace,
    resolve_agent_checked,
};
use jiff::Timestamp;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

#[derive(Debug, Serialize, Deserialize)]
pub struct ResolveData {
    pub changed: bool,
    pub records: Vec<ListItem>,
}

pub fn run(
    args: ResolveArgs,
    file: Option<PathBuf>,
    pretty: bool,
    now: Timestamp,
) -> AppResult<i32> {
    let ResolveArgs {
        ids,
        note,
        agent: requested_agent,
        task,
        pr,
        commit,
        url,
        dropped,
        amend,
        dry_run,
    } = args;
    let prefixes: Vec<_> = ids
        .iter()
        .map(|id| normalize_prefix(id))
        .collect::<AppResult<_>>()?;
    let resolved = store::discover(file)?;
    for (flag, value) in [
        ("task", task.as_deref()),
        ("pr", pr.as_deref()),
        ("commit", commit.as_deref()),
        ("url", url.as_deref()),
    ] {
        if value.is_some_and(|value| value.trim().is_empty()) {
            return Err(AppError::invalid_input(
                format!("{flag} cannot be empty or whitespace-only"),
                format!("Pass a non-empty --{flag} VALUE or omit the flag."),
            ));
        }
    }
    if amend
        && note.is_none()
        && task.is_none()
        && pr.is_none()
        && commit.is_none()
        && url.is_none()
        && !dropped
    {
        return Err(AppError::invalid_input(
            "--amend requires at least one resolution field",
            "Pass --note, --task, --pr, --commit, --url, or --dropped with --amend.",
        ));
    }
    let (agent, source) = resolve_agent_checked(requested_agent, false)?;
    let ts = format_timestamp(now);
    let action = |log: &mut std::fs::File| -> AppResult<(bool, Vec<String>, Vec<ListItem>)> {
        let bytes = store::read_bytes(log, &resolved.path)?;
        let folded = store::fold_bytes(&bytes);
        let mut ids = prefixes
            .iter()
            .map(|prefix| match_id(prefix, &folded.items))
            .collect::<AppResult<Vec<_>>>()?;
        ids.sort();
        ids.dedup();
        let mut items = ids
            .iter()
            .map(|id| {
                folded
                    .items
                    .iter()
                    .find(|item| item.id == *id)
                    .cloned()
                    .ok_or_else(|| AppError::internal("matched cut disappeared during resolution"))
            })
            .collect::<AppResult<Vec<_>>>()?;
        if (url.is_some() || dropped) && items.iter().any(|item| item.kind != "dogear") {
            return Err(AppError::invalid_argument(
                "--url and --dropped may only resolve dogear records",
                "Use --url or --dropped only with dogear IDs, or resolve cuts without those flags.",
            ));
        }
        if amend && items.iter().any(|item| item.status != ItemStatus::Resolved) {
            return Err(AppError::invalid_input(
                "--amend requires every requested record to be resolved",
                "Resolve each record without --amend first, then retry with --amend.",
            ));
        }
        let already_resolved_ids: Vec<_> = if amend {
            Vec::new()
        } else {
            ids.iter()
                .zip(&items)
                .filter(|(_, item)| item.status == ItemStatus::Resolved)
                .map(|(id, _)| id.clone())
                .collect()
        };
        let mut changed = false;
        if !dry_run {
            let mut events = Vec::new();
            let mut updated_item_indexes = Vec::new();
            for (item_index, (id, item)) in ids.iter().zip(&items).enumerate() {
                if !amend && item.status == ItemStatus::Resolved {
                    continue;
                }
                events.push(LogEvent::Resolve {
                    id: id.clone(),
                    ts: ts.clone(),
                    agent: agent.clone(),
                    note: note.clone(),
                    task: task.clone(),
                    pr: pr.clone(),
                    commit: commit.clone(),
                    url: url.clone(),
                    dropped,
                    amend,
                });
                updated_item_indexes.push(item_index);
            }
            if !events.is_empty() {
                store::append_json_batch(log, &resolved.path, &bytes, &events)?;
                changed = true;
                for (item_index, event) in updated_item_indexes.into_iter().zip(&events) {
                    let item = &mut items[item_index];
                    item.status = ItemStatus::Resolved;
                    item.resolution = Some(folded.materialized_appended_resolution(event));
                }
            }
        } else {
            for item in &mut items {
                if amend || item.status == ItemStatus::Open {
                    item.status = ItemStatus::Resolved;
                    item.resolution = Some(Resolution {
                        ts: ts.clone(),
                        agent: agent.clone(),
                        note: note.clone(),
                        task: task.clone(),
                        pr: pr.clone(),
                        commit: commit.clone(),
                        url: url.clone(),
                        dropped,
                        amended: amend,
                    });
                }
            }
        }
        Ok((changed, already_resolved_ids, items))
    };
    let (changed, already_resolved_ids, records) = if dry_run {
        store::with_shared(&resolved.path, action)
    } else {
        store::with_exclusive(&resolved.path, false, action)
    }?;
    let mut meta = Meta::new();
    meta.file = Some(resolved.path.to_string_lossy().into_owned());
    meta.agent_source = Some(source.into());
    meta.warnings = resolved.warnings.clone();
    if already_resolved_ids.len() == records.len() {
        meta.warnings.push("already resolved".into());
    } else if !already_resolved_ids.is_empty() {
        let noun = if already_resolved_ids.len() == 1 {
            "ID"
        } else {
            "IDs"
        };
        meta.warnings.push(format!(
            "already resolved: {} {noun} ({})",
            already_resolved_ids.len(),
            already_resolved_ids.join(", ")
        ));
    } else if dry_run {
        meta.warnings
            .push("dry run; no resolve event appended".into());
    }
    output::write_success(ResolveData { changed, records }, pretty, meta)
        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
    Ok(0)
}

#[derive(Debug)]
struct IdPrefix {
    namespace: Option<IdNamespace>,
    hex: String,
}

fn normalize_prefix(input: &str) -> AppResult<IdPrefix> {
    let namespace = id_namespace(input);
    let hex = namespace.map_or(input, |_| &input[3..]);
    if hex.len() < 4 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
        return Err(AppError::invalid_argument(
            format!("invalid cut ID prefix '{input}'"),
            "Use `blotter list --status all --include-auto` and pass at least 4 hexadecimal digits, with optional bl_ or pc_ prefix.",
        ));
    }
    Ok(IdPrefix {
        namespace,
        hex: hex.to_ascii_lowercase(),
    })
}

fn match_id(prefix: &IdPrefix, items: &[ListItem]) -> AppResult<String> {
    let mut candidates: Vec<_> = items
        .iter()
        .map(|item| item.id.clone())
        .filter(|id| {
            id_namespace(id).is_some_and(|namespace| {
                prefix
                    .namespace
                    .is_none_or(|expected| expected == namespace)
                    && id
                        .get(3..)
                        .is_some_and(|hex| hex.to_ascii_lowercase().starts_with(&prefix.hex))
            })
        })
        .collect();
    candidates.sort();
    match candidates.as_slice() {
        [] => Err(AppError::not_found(
            format!("no cut matches ID prefix '{}'", prefix.hex),
            "Run `blotter list --status all --include-auto` and retry with a listed ID.",
        )),
        [id] => Ok(id.clone()),
        _ => Err(AppError::ambiguous_id(&prefix.hex, candidates)),
    }
}