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}