Skip to main content

blotter/commands/
resolve.rs

1use crate::cli::ResolveArgs;
2use crate::commands::add::redact_evidence;
3use crate::error::{AppError, AppResult};
4use crate::output::{self, Meta};
5use crate::store;
6use crate::{
7    Disposition, ItemStatus, ListItem, LogEvent, format_timestamp, is_bl_id, 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        disposition,
35        promotion,
36        amend,
37        dry_run,
38    } = args;
39    let prefixes: Vec<_> = ids
40        .iter()
41        .map(|id| normalize_prefix(id))
42        .collect::<AppResult<_>>()?;
43    // `--promotion` is accepted only alongside an explicit `--disposition
44    // promoted` (r48). An amend that omits the disposition inherits its link
45    // instead of naming one, so this is an argument rule and needs no fold.
46    let promotion_prefix = promotion
47        .as_deref()
48        .map(|value| {
49            if disposition != Some(Disposition::Promoted) {
50                return Err(AppError::invalid_argument(
51                    "--promotion requires --disposition promoted",
52                    "Pass --disposition promoted with --promotion ID, or drop --promotion.",
53                ));
54            }
55            normalize_prefix(value)
56        })
57        .transpose()?;
58    let resolved = store::discover(file)?;
59    for (flag, value) in [
60        ("task", task.as_deref()),
61        ("pr", pr.as_deref()),
62        ("commit", commit.as_deref()),
63        ("url", url.as_deref()),
64    ] {
65        if value.is_some_and(|value| value.trim().is_empty()) {
66            return Err(AppError::invalid_input(
67                format!("{flag} cannot be empty or whitespace-only"),
68                format!("Pass a non-empty --{flag} VALUE or omit the flag."),
69            ));
70        }
71    }
72    if amend
73        && note.is_none()
74        && task.is_none()
75        && pr.is_none()
76        && commit.is_none()
77        && url.is_none()
78        && !dropped
79        && disposition.is_none()
80    {
81        return Err(AppError::invalid_input(
82            "--amend requires at least one resolution field",
83            "Pass --note, --task, --pr, --commit, --url, --dropped, or --disposition with --amend.",
84        ));
85    }
86    // Redact once, ahead of the critical section, so the base and amend paths —
87    // and the dry-run prediction — all carry the same bytes the append stores.
88    let home = store::home_dir(&resolved.cwd);
89    let note = note.map(|value| redact_evidence(&value, home.as_deref()));
90    let (agent, source) = resolve_agent_checked(requested_agent, false)?;
91    let ts = format_timestamp(now);
92    let action = |log: &mut std::fs::File| -> AppResult<(bool, Vec<String>, Vec<ListItem>)> {
93        let bytes = store::read_bytes(log, &resolved.path)?;
94        store::check_version(&bytes, &resolved.path)?;
95        let folded = store::fold_bytes(&bytes);
96        let candidates = candidates(&folded);
97        let mut ids = prefixes
98            .iter()
99            .map(|prefix| {
100                let Candidate { id, kind } = match_id(prefix, &candidates)?;
101                // A promotion has no status and no resolution; it is never a
102                // resolve target (r48).
103                if kind == "promotion" {
104                    return Err(AppError::invalid_argument(
105                        format!("{id} is a promotion, which is never resolved"),
106                        "Resolve cut or dogear IDs; a promotion has no lifecycle.",
107                    ));
108                }
109                Ok(id)
110            })
111            .collect::<AppResult<Vec<_>>>()?;
112        ids.sort();
113        ids.dedup();
114        // Resolved under the same lock, before any append.
115        let promotion = promotion_prefix
116            .as_ref()
117            .map(|prefix| {
118                let Candidate { id, kind } = match_id(prefix, &candidates)?;
119                if kind != "promotion" {
120                    return Err(AppError::invalid_argument(
121                        format!("--promotion {id} is a {kind}, not a promotion"),
122                        "Pass a promotion ID to --promotion; run `blotter list --kind promotion`.",
123                    ));
124                }
125                Ok(id)
126            })
127            .transpose()?;
128        let mut items = ids
129            .iter()
130            .map(|id| {
131                folded
132                    .items
133                    .iter()
134                    .find(|item| item.id == *id)
135                    .cloned()
136                    .ok_or_else(|| AppError::internal("matched cut disappeared during resolution"))
137            })
138            .collect::<AppResult<Vec<_>>>()?;
139        if (url.is_some() || dropped) && items.iter().any(|item| item.kind != "dogear") {
140            return Err(AppError::invalid_argument(
141                "--url and --dropped may only resolve dogear records",
142                "Use --url or --dropped only with dogear IDs, or resolve cuts without those flags.",
143            ));
144        }
145        // Every disposition rule needs the folded kind of each named record, so
146        // all three sit inside the critical section, after ID matching and
147        // before any append. A batch that fails one appends nothing at all.
148        let has_cut = items.iter().any(|item| item.kind == "cut");
149        let has_dogear = items.iter().any(|item| item.kind == "dogear");
150        if has_cut && has_dogear {
151            return Err(AppError::invalid_argument(
152                "a resolve batch cannot name both cut and dogear records",
153                "Resolve cuts and dogears in separate commands; a cut requires --disposition and a dogear rejects it.",
154            ));
155        }
156        if disposition.is_some() && has_dogear {
157            return Err(AppError::invalid_argument(
158                "--disposition may only resolve cut records",
159                "Use --disposition only with cut IDs; a dogear's lifecycle is --url or --dropped.",
160            ));
161        }
162        if disposition.is_none() && has_cut && !amend {
163            return Err(AppError::invalid_argument(
164                "--disposition is required when resolving a cut",
165                "Pass --disposition fixed|promoted|accepted|invalid.",
166            ));
167        }
168        if amend && items.iter().any(|item| item.status != ItemStatus::Resolved) {
169            return Err(AppError::invalid_input(
170                "--amend requires every requested record to be resolved",
171                "Resolve each record without --amend first, then retry with --amend.",
172            ));
173        }
174        // The link must be mutual (r48): every cut resolved with `--promotion P`
175        // must already appear in `P.sources`. Checked here, under the same lock
176        // and before any append, so a failure fails the whole batch.
177        let link = |item: &ListItem| -> AppResult<Option<String>> {
178            let promotion = effective_promotion(item, disposition, promotion.as_deref());
179            if let Some(promotion_id) = promotion.as_deref()
180                && !folded.promotions.iter().any(|candidate| {
181                    candidate.id == promotion_id && candidate.sources.contains(&item.id)
182                })
183            {
184                return Err(AppError::invalid_argument(
185                    format!(
186                        "promotion {promotion_id} does not name {} as a source",
187                        item.id
188                    ),
189                    "Run `blotter promote --source <cut id>` first, then resolve the cut against that promotion.",
190                ));
191            }
192            Ok(promotion)
193        };
194        let already_resolved_ids: Vec<_> = if amend {
195            Vec::new()
196        } else {
197            ids.iter()
198                .zip(&items)
199                .filter(|(_, item)| item.status == ItemStatus::Resolved)
200                .map(|(id, _)| id.clone())
201                .collect()
202        };
203        // The **named** set, not the set that will carry an event. r48 gives
204        // this rule "the same all-or-nothing shape as the mixed-kind
205        // rejection", and both named precedents — the mixed cut/dogear batch
206        // and the missing `--disposition` — fire on a named record that is
207        // already resolved and will append nothing.
208        for item in &items {
209            link(item)?;
210        }
211        let mut changed = false;
212        if !dry_run {
213            let mut events = Vec::new();
214            let mut updated_item_indexes = Vec::new();
215            for (item_index, (id, item)) in ids.iter().zip(&items).enumerate() {
216                if !amend && item.status == ItemStatus::Resolved {
217                    continue;
218                }
219                let (disposition, disposition_ts) =
220                    event_disposition(item, disposition, ts.as_str());
221                let promotion = link(item)?;
222                events.push(LogEvent::Resolve {
223                    id: id.clone(),
224                    ts: ts.clone(),
225                    agent: agent.clone(),
226                    note: note.clone(),
227                    task: task.clone(),
228                    pr: pr.clone(),
229                    commit: commit.clone(),
230                    url: url.clone(),
231                    dropped,
232                    amend,
233                    disposition,
234                    disposition_ts,
235                    promotion,
236                });
237                updated_item_indexes.push(item_index);
238            }
239            if !events.is_empty() {
240                store::append_json_batch(log, &resolved.path, &bytes, &events)?;
241                changed = true;
242                for (item_index, event) in updated_item_indexes.into_iter().zip(&events) {
243                    let item = &mut items[item_index];
244                    item.status = ItemStatus::Resolved;
245                    item.resolution = Some(folded.materialized_appended_resolution(event));
246                }
247            }
248        } else {
249            // Predict through the same rule the apply path uses, so a dry run
250            // cannot promise a resolution the real append would not produce —
251            // an amend backdated behind a stored one does not win.
252            for (id, item) in ids.iter().zip(&mut items) {
253                if amend || item.status == ItemStatus::Open {
254                    let (disposition, disposition_ts) =
255                        event_disposition(item, disposition, ts.as_str());
256                    let promotion = link(item)?;
257                    let candidate = LogEvent::Resolve {
258                        id: id.clone(),
259                        ts: ts.clone(),
260                        agent: agent.clone(),
261                        note: note.clone(),
262                        task: task.clone(),
263                        pr: pr.clone(),
264                        commit: commit.clone(),
265                        url: url.clone(),
266                        dropped,
267                        amend,
268                        disposition,
269                        disposition_ts,
270                        promotion,
271                    };
272                    item.status = ItemStatus::Resolved;
273                    item.resolution = Some(folded.materialized_appended_resolution(&candidate));
274                }
275            }
276        }
277        Ok((changed, already_resolved_ids, items))
278    };
279    let (changed, already_resolved_ids, records) = if dry_run {
280        store::with_shared(&resolved.path, action)
281    } else {
282        store::with_exclusive(&resolved.path, false, action)
283    }?;
284    let mut meta = Meta::new();
285    meta.file = Some(resolved.path.to_string_lossy().into_owned());
286    meta.agent_source = Some(source.into());
287    meta.warnings = resolved.warnings.clone();
288    if already_resolved_ids.len() == records.len() {
289        meta.warnings.push("already resolved".into());
290    } else if !already_resolved_ids.is_empty() {
291        let noun = if already_resolved_ids.len() == 1 {
292            "ID"
293        } else {
294            "IDs"
295        };
296        meta.warnings.push(format!(
297            "already resolved: {} {noun} ({})",
298            already_resolved_ids.len(),
299            already_resolved_ids.join(", ")
300        ));
301    }
302    // The two warnings answer different questions — which IDs were already
303    // resolved, and whether anything was appended — so a dry run says so
304    // whatever the mix. Chained behind the already-resolved arms, a mixed dry
305    // run and a mixed real run emitted an identical warning set, and only
306    // `data.changed` told a consumer which one it was reading.
307    if dry_run {
308        meta.warnings
309            .push("dry run; no resolve event appended".into());
310    }
311    output::write_success(ResolveData { changed, records }, pretty, meta)
312        .map_err(|error| AppError::from_io(error, std::path::Path::new("stdout")))?;
313    Ok(0)
314}
315
316/// The disposition an event carries, and the moment that disposition was
317/// decided. An explicit `--disposition` stamps this event's own `ts`; an amend
318/// that omits it copies both from the **pre-append folded winner** — what `list`
319/// shows the instant before this command runs (r50) — so inheritance is visible
320/// in the stored bytes and needs no fold rule to reconstruct.
321fn event_disposition(
322    item: &ListItem,
323    requested: Option<Disposition>,
324    ts: &str,
325) -> (Option<Disposition>, Option<String>) {
326    if item.kind != "cut" {
327        return (None, None);
328    }
329    match requested {
330        Some(disposition) => (Some(disposition), Some(ts.to_owned())),
331        None => {
332            let resolution = item.resolution.as_ref();
333            (
334                resolution.and_then(|resolution| resolution.disposition),
335                resolution.and_then(|resolution| resolution.disposition_ts.clone()),
336            )
337        }
338    }
339}
340
341/// The promotion link an event carries. An explicit `--promotion` names it; an
342/// explicit `--disposition promoted` without one keeps the winner's link, and
343/// any other explicit disposition clears it; an amend that omits `--disposition`
344/// inherits the link along with the disposition it belongs to (r48).
345fn effective_promotion(
346    item: &ListItem,
347    requested_disposition: Option<Disposition>,
348    requested_promotion: Option<&str>,
349) -> Option<String> {
350    if item.kind != "cut" {
351        return None;
352    }
353    if let Some(promotion) = requested_promotion {
354        return Some(promotion.to_owned());
355    }
356    let inherited = || {
357        item.resolution
358            .as_ref()
359            .and_then(|resolution| resolution.promotion.clone())
360    };
361    match requested_disposition {
362        Some(Disposition::Promoted) | None => inherited(),
363        Some(_) => None,
364    }
365}
366
367/// An ID prefix: optional `bl_`, then at least 4 hex digits, matched
368/// case-insensitively against the one `bl2` namespace (r48). No exact-full-ID
369/// precedence: a complete ID that also prefixes a longer one is `ambiguous_id`
370/// (r50).
371#[derive(Debug)]
372pub(crate) struct IdPrefix {
373    hex: String,
374}
375
376/// One folded identity-bearing record, with the kind a flag may reject it for.
377/// Ambiguity is decided before kind, so every ID argument — a `resolve`
378/// positional, `--promotion`, and `promote --source` — matches against the
379/// folded distinct IDs of all three kinds and only then answers on the kind.
380pub(crate) struct Candidate {
381    pub id: String,
382    pub kind: &'static str,
383}
384
385/// The candidate set the single prefix-resolution rule matches against: every
386/// folded cut, dogear, and promotion. Orphan resolve events are never
387/// candidates, as in v1.
388pub(crate) fn candidates(folded: &store::FoldResult) -> Vec<Candidate> {
389    folded
390        .items
391        .iter()
392        .map(|item| Candidate {
393            id: item.id.clone(),
394            kind: if item.kind == "cut" { "cut" } else { "dogear" },
395        })
396        .chain(folded.promotions.iter().map(|promotion| Candidate {
397            id: promotion.id.clone(),
398            kind: "promotion",
399        }))
400        .collect()
401}
402
403pub(crate) fn normalize_prefix(input: &str) -> AppResult<IdPrefix> {
404    let hex = if is_bl_id(input) { &input[3..] } else { input };
405    if hex.len() < 4 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
406        return Err(AppError::invalid_argument(
407            format!("invalid record ID prefix '{input}'"),
408            "Use `blotter list --kind all --status all` and pass at least 4 hexadecimal digits, with an optional bl_ prefix.",
409        ));
410    }
411    Ok(IdPrefix {
412        hex: hex.to_ascii_lowercase(),
413    })
414}
415
416pub(crate) fn match_id(prefix: &IdPrefix, candidates: &[Candidate]) -> AppResult<Candidate> {
417    let mut matched: Vec<_> = candidates
418        .iter()
419        .filter(|candidate| {
420            is_bl_id(&candidate.id)
421                && candidate
422                    .id
423                    .get(3..)
424                    .is_some_and(|hex| hex.to_ascii_lowercase().starts_with(&prefix.hex))
425        })
426        .collect();
427    matched.sort_by(|left, right| left.id.cmp(&right.id));
428    match matched.as_slice() {
429        [] => Err(AppError::not_found(
430            format!("no record matches ID prefix '{}'", prefix.hex),
431            "Run `blotter list --kind all --status all` and retry with a listed ID.",
432        )),
433        [candidate] => Ok(Candidate {
434            id: candidate.id.clone(),
435            kind: candidate.kind,
436        }),
437        _ => Err(AppError::ambiguous_id(
438            &prefix.hex,
439            matched
440                .iter()
441                .map(|candidate| candidate.id.clone())
442                .collect(),
443        )),
444    }
445}