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 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 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 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 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 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 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 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 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 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
316fn 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
341fn 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#[derive(Debug)]
372pub(crate) struct IdPrefix {
373 hex: String,
374}
375
376pub(crate) struct Candidate {
381 pub id: String,
382 pub kind: &'static str,
383}
384
385pub(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}