Skip to main content

blotter/commands/
resolve.rs

1use crate::cli::ResolveArgs;
2use crate::error::{AppError, AppResult};
3use crate::output::{self, Meta};
4use crate::store;
5use crate::{
6    IdNamespace, ItemStatus, ListItem, LogEvent, Resolution, format_timestamp, id_namespace,
7    resolve_agent_checked,
8};
9use jiff::Timestamp;
10use serde::{Deserialize, Serialize};
11use std::path::PathBuf;
12
13#[derive(Debug, Serialize, Deserialize)]
14pub struct ResolveData {
15    pub changed: bool,
16    pub records: Vec<ListItem>,
17}
18
19pub fn run(
20    args: ResolveArgs,
21    file: Option<PathBuf>,
22    pretty: bool,
23    now: Timestamp,
24) -> AppResult<i32> {
25    let ResolveArgs {
26        ids,
27        note,
28        agent: requested_agent,
29        task,
30        pr,
31        commit,
32        url,
33        dropped,
34        amend,
35        dry_run,
36    } = args;
37    let prefixes: Vec<_> = ids
38        .iter()
39        .map(|id| normalize_prefix(id))
40        .collect::<AppResult<_>>()?;
41    let resolved = store::discover(file)?;
42    for (flag, value) in [
43        ("task", task.as_deref()),
44        ("pr", pr.as_deref()),
45        ("commit", commit.as_deref()),
46        ("url", url.as_deref()),
47    ] {
48        if value.is_some_and(|value| value.trim().is_empty()) {
49            return Err(AppError::invalid_input(
50                format!("{flag} cannot be empty or whitespace-only"),
51                format!("Pass a non-empty --{flag} VALUE or omit the flag."),
52            ));
53        }
54    }
55    if amend
56        && note.is_none()
57        && task.is_none()
58        && pr.is_none()
59        && commit.is_none()
60        && url.is_none()
61        && !dropped
62    {
63        return Err(AppError::invalid_input(
64            "--amend requires at least one resolution field",
65            "Pass --note, --task, --pr, --commit, --url, or --dropped with --amend.",
66        ));
67    }
68    let (agent, source) = resolve_agent_checked(requested_agent, false)?;
69    let ts = format_timestamp(now);
70    let action = |log: &mut std::fs::File| -> AppResult<(bool, Vec<String>, Vec<ListItem>)> {
71        let bytes = store::read_bytes(log, &resolved.path)?;
72        let folded = store::fold_bytes(&bytes);
73        let mut ids = prefixes
74            .iter()
75            .map(|prefix| match_id(prefix, &folded.items))
76            .collect::<AppResult<Vec<_>>>()?;
77        ids.sort();
78        ids.dedup();
79        let mut items = ids
80            .iter()
81            .map(|id| {
82                folded
83                    .items
84                    .iter()
85                    .find(|item| item.id == *id)
86                    .cloned()
87                    .ok_or_else(|| AppError::internal("matched cut disappeared during resolution"))
88            })
89            .collect::<AppResult<Vec<_>>>()?;
90        if (url.is_some() || dropped) && items.iter().any(|item| item.kind != "dogear") {
91            return Err(AppError::invalid_argument(
92                "--url and --dropped may only resolve dogear records",
93                "Use --url or --dropped only with dogear IDs, or resolve cuts without those flags.",
94            ));
95        }
96        if amend && items.iter().any(|item| item.status != ItemStatus::Resolved) {
97            return Err(AppError::invalid_input(
98                "--amend requires every requested record to be resolved",
99                "Resolve each record without --amend first, then retry with --amend.",
100            ));
101        }
102        let already_resolved_ids: Vec<_> = if amend {
103            Vec::new()
104        } else {
105            ids.iter()
106                .zip(&items)
107                .filter(|(_, item)| item.status == ItemStatus::Resolved)
108                .map(|(id, _)| id.clone())
109                .collect()
110        };
111        let mut changed = false;
112        if !dry_run {
113            let mut events = Vec::new();
114            let mut updated_item_indexes = Vec::new();
115            for (item_index, (id, item)) in ids.iter().zip(&items).enumerate() {
116                if !amend && item.status == ItemStatus::Resolved {
117                    continue;
118                }
119                events.push(LogEvent::Resolve {
120                    id: id.clone(),
121                    ts: ts.clone(),
122                    agent: agent.clone(),
123                    note: note.clone(),
124                    task: task.clone(),
125                    pr: pr.clone(),
126                    commit: commit.clone(),
127                    url: url.clone(),
128                    dropped,
129                    amend,
130                });
131                updated_item_indexes.push(item_index);
132            }
133            if !events.is_empty() {
134                store::append_json_batch(log, &resolved.path, &bytes, &events)?;
135                changed = true;
136                for (item_index, event) in updated_item_indexes.into_iter().zip(&events) {
137                    let item = &mut items[item_index];
138                    item.status = ItemStatus::Resolved;
139                    item.resolution = Some(folded.materialized_appended_resolution(event));
140                }
141            }
142        } else {
143            for item in &mut items {
144                if amend || item.status == ItemStatus::Open {
145                    item.status = ItemStatus::Resolved;
146                    item.resolution = Some(Resolution {
147                        ts: ts.clone(),
148                        agent: agent.clone(),
149                        note: note.clone(),
150                        task: task.clone(),
151                        pr: pr.clone(),
152                        commit: commit.clone(),
153                        url: url.clone(),
154                        dropped,
155                        amended: amend,
156                    });
157                }
158            }
159        }
160        Ok((changed, already_resolved_ids, items))
161    };
162    let (changed, already_resolved_ids, records) = if dry_run {
163        store::with_shared(&resolved.path, action)
164    } else {
165        store::with_exclusive(&resolved.path, false, action)
166    }?;
167    let mut meta = Meta::new();
168    meta.file = Some(resolved.path.to_string_lossy().into_owned());
169    meta.agent_source = Some(source.into());
170    meta.warnings = resolved.warnings.clone();
171    if already_resolved_ids.len() == records.len() {
172        meta.warnings.push("already resolved".into());
173    } else if !already_resolved_ids.is_empty() {
174        let noun = if already_resolved_ids.len() == 1 {
175            "ID"
176        } else {
177            "IDs"
178        };
179        meta.warnings.push(format!(
180            "already resolved: {} {noun} ({})",
181            already_resolved_ids.len(),
182            already_resolved_ids.join(", ")
183        ));
184    } else if dry_run {
185        meta.warnings
186            .push("dry run; no resolve event appended".into());
187    }
188    output::write_success(ResolveData { changed, records }, pretty, meta)
189        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
190    Ok(0)
191}
192
193#[derive(Debug)]
194struct IdPrefix {
195    namespace: Option<IdNamespace>,
196    hex: String,
197}
198
199fn normalize_prefix(input: &str) -> AppResult<IdPrefix> {
200    let namespace = id_namespace(input);
201    let hex = namespace.map_or(input, |_| &input[3..]);
202    if hex.len() < 4 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
203        return Err(AppError::invalid_argument(
204            format!("invalid cut ID prefix '{input}'"),
205            "Use `blotter list --status all --include-auto` and pass at least 4 hexadecimal digits, with optional bl_ or pc_ prefix.",
206        ));
207    }
208    Ok(IdPrefix {
209        namespace,
210        hex: hex.to_ascii_lowercase(),
211    })
212}
213
214fn match_id(prefix: &IdPrefix, items: &[ListItem]) -> AppResult<String> {
215    let mut candidates: Vec<_> = items
216        .iter()
217        .map(|item| item.id.clone())
218        .filter(|id| {
219            id_namespace(id).is_some_and(|namespace| {
220                prefix
221                    .namespace
222                    .is_none_or(|expected| expected == namespace)
223                    && id
224                        .get(3..)
225                        .is_some_and(|hex| hex.to_ascii_lowercase().starts_with(&prefix.hex))
226            })
227        })
228        .collect();
229    candidates.sort();
230    match candidates.as_slice() {
231        [] => Err(AppError::not_found(
232            format!("no cut matches ID prefix '{}'", prefix.hex),
233            "Run `blotter list --status all --include-auto` and retry with a listed ID.",
234        )),
235        [id] => Ok(id.clone()),
236        _ => Err(AppError::ambiguous_id(&prefix.hex, candidates)),
237    }
238}