1use anyhow::Result;
5use chrono::Utc;
6use objects::{
7 lock::RepositoryLockExt,
8 object::{Annotation, AnnotationStatus, ContextBlob},
9};
10use repo::{Repository, compute_rewrite_pct};
11
12use super::{
13 apply_new_state, build_context_state, compute_source_hash, parse_kind, parse_scope,
14 read_annotation_content, resolve_scope_at_target, resolve_state, resolve_target, target_label,
15};
16use crate::{
17 cli::{Cli, commands::snapshot::resolve_attribution, should_output_json},
18 config::UserConfig,
19};
20
21#[allow(clippy::too_many_arguments)]
23pub async fn cmd_context_set(
24 cli: &Cli,
25 path: Option<String>,
26 state: Option<String>,
27 scope: Option<String>,
28 kind: String,
29 tags: Vec<String>,
30 message: Option<String>,
31 file: Option<std::path::PathBuf>,
32) -> Result<()> {
33 let repo = Repository::open(cli.repo.as_ref().unwrap_or(&std::env::current_dir()?))?;
34 let target = resolve_target(&repo, path, state)?;
35 let scope = parse_scope(scope.as_deref())?;
36 target.validate_scope(&scope)?;
37 let kind = parse_kind(Some(&kind))?;
38 let content = read_annotation_content(message, file)?;
39
40 let _lock = repo.locker().write().map_err(|e| anyhow::anyhow!("{e}"))?;
41 let head_state = resolve_state(&repo, None)?;
42 let scope = resolve_scope_at_target(&repo, &target, scope)?;
47 let source_hash = compute_source_hash(&repo, &target, &scope);
48 let user_config = UserConfig::load_default()?;
49 let attribution = resolve_attribution(&repo, &user_config)?;
50 let annotation = Annotation::new(
51 scope,
52 kind,
53 content,
54 tags,
55 attribution.to_string(),
56 Utc::now().timestamp(),
57 source_hash,
58 Some(head_state.change_id),
59 );
60
61 let mut blob = match &head_state.context {
62 Some(root) => repo
63 .get_context_blob(root, &target)?
64 .unwrap_or_else(|| ContextBlob::new(vec![])),
65 None => ContextBlob::new(vec![]),
66 };
67 blob.annotations.push(annotation);
68 let new_context_root = repo.set_context_blob(head_state.context.as_ref(), &target, &blob)?;
69 let (_, label) = target_label(&target);
70 let new_state = build_context_state(
71 &repo,
72 &head_state,
73 Some(new_context_root),
74 format!("context: annotate {label}"),
75 )?;
76 apply_new_state(&repo, &new_state)?;
77
78 if should_output_json(cli, None) {
79 println!(
80 "{}",
81 serde_json::json!({
82 "target": label,
83 "annotations": blob.annotations.len(),
84 "state": new_state.change_id.short(),
85 })
86 );
87 } else {
88 println!(
89 "Annotated {} ({} active annotation{})",
90 label,
91 blob.annotations
92 .iter()
93 .filter(|annotation| annotation.status == AnnotationStatus::Active)
94 .count(),
95 if blob.annotations.len() == 1 { "" } else { "s" }
96 );
97 }
98
99 Ok(())
100}
101
102pub async fn cmd_context_edit(
103 cli: &Cli,
104 annotation_id: String,
105 kind: Option<String>,
106 tags: Vec<String>,
107 message: Option<String>,
108 file: Option<std::path::PathBuf>,
109) -> Result<()> {
110 let repo = Repository::open(cli.repo.as_ref().unwrap_or(&std::env::current_dir()?))?;
111 let content = read_annotation_content(message, file)?;
112 let _lock = repo.locker().write().map_err(|e| anyhow::anyhow!("{e}"))?;
113 let head_state = resolve_state(&repo, None)?;
114 let context_root = head_state
115 .context
116 .as_ref()
117 .ok_or_else(|| anyhow::anyhow!("No context annotations in this repository"))?;
118
119 let (target, mut blob, index) = repo
120 .find_annotation(context_root, &annotation_id)?
121 .ok_or_else(|| anyhow::anyhow!("Annotation not found: {annotation_id}"))?;
122
123 let annotation = blob
124 .annotations
125 .get_mut(index)
126 .ok_or_else(|| anyhow::anyhow!("Annotation index out of range"))?;
127 let current = annotation.current_revision().cloned().unwrap();
128 let next_kind = match kind.as_deref() {
129 Some(kind) => parse_kind(Some(kind))?,
130 None => current.kind,
131 };
132 let next_tags = if tags.is_empty() {
133 current.tags.clone()
134 } else {
135 tags
136 };
137 annotation.scope = resolve_scope_at_target(&repo, &target, annotation.scope.clone())?;
138 let source_hash = compute_source_hash(&repo, &target, &annotation.scope);
139 let user_config = UserConfig::load_default()?;
140 let attribution = resolve_attribution(&repo, &user_config)?;
141 annotation.revise(
142 next_kind,
143 content,
144 next_tags,
145 attribution.to_string(),
146 Utc::now().timestamp(),
147 source_hash,
148 Some(head_state.change_id),
149 );
150 let revision_count = annotation.revisions.len();
151 let _ = annotation;
152
153 let new_context_root = repo.set_context_blob(Some(context_root), &target, &blob)?;
154 let (_, label) = target_label(&target);
155 let new_state = build_context_state(
156 &repo,
157 &head_state,
158 Some(new_context_root),
159 format!("context: revise {label}"),
160 )?;
161 apply_new_state(&repo, &new_state)?;
162
163 if should_output_json(cli, None) {
164 println!(
165 "{}",
166 serde_json::json!({
167 "annotation_id": annotation_id,
168 "state": new_state.change_id.short(),
169 "revision_count": revision_count,
170 })
171 );
172 } else {
173 println!("Revised annotation {}", annotation_id);
174 }
175
176 Ok(())
177}
178
179#[allow(clippy::too_many_arguments)]
180pub async fn cmd_context_supersede(
181 cli: &Cli,
182 annotation_id: String,
183 path: Option<String>,
184 state: Option<String>,
185 scope: Option<String>,
186 kind: String,
187 tags: Vec<String>,
188 message: Option<String>,
189 file: Option<std::path::PathBuf>,
190) -> Result<()> {
191 let repo = Repository::open(cli.repo.as_ref().unwrap_or(&std::env::current_dir()?))?;
192 let content = read_annotation_content(message, file)?;
193 let _lock = repo.locker().write().map_err(|e| anyhow::anyhow!("{e}"))?;
194 let head_state = resolve_state(&repo, None)?;
195 let context_root = head_state
196 .context
197 .as_ref()
198 .ok_or_else(|| anyhow::anyhow!("No context annotations in this repository"))?;
199
200 let (original_target, mut original_blob, index) = repo
201 .find_annotation(context_root, &annotation_id)?
202 .ok_or_else(|| anyhow::anyhow!("Annotation not found: {annotation_id}"))?;
203 let original_annotation = original_blob.annotations[index].clone();
204 let original_revision = original_annotation.current_revision().cloned().unwrap();
205
206 let target = match (path, state) {
207 (None, None) => original_target.clone(),
208 (path, state) => resolve_target(&repo, path, state)?,
209 };
210 let replacement_scope = match scope.as_deref() {
211 Some(scope) => parse_scope(Some(scope))?,
212 None => original_annotation.scope.clone(),
213 };
214 target.validate_scope(&replacement_scope)?;
215 let replacement_scope = resolve_scope_at_target(&repo, &target, replacement_scope)?;
216 let kind = parse_kind(Some(&kind))?;
217 let source_hash = compute_source_hash(&repo, &target, &replacement_scope);
218 let rewrite_pct = compute_rewrite_pct(&original_revision.content, &content);
219 let user_config = UserConfig::load_default()?;
220 let attribution = resolve_attribution(&repo, &user_config)?;
221 let mut replacement = Annotation::new(
222 replacement_scope,
223 kind,
224 content,
225 tags,
226 attribution.to_string(),
227 Utc::now().timestamp(),
228 source_hash,
229 Some(head_state.change_id),
230 );
231 replacement.supersedes_annotation_id = Some(annotation_id.clone());
232 replacement.supersedes_rewrite_pct = Some(rewrite_pct);
233
234 original_blob.annotations[index].mark_superseded();
235 let mut next_root =
236 repo.set_context_blob(Some(context_root), &original_target, &original_blob)?;
237
238 let mut replacement_blob = if target == original_target {
239 original_blob
240 } else {
241 repo.get_context_blob(&next_root, &target)?
242 .unwrap_or_else(|| ContextBlob::new(vec![]))
243 };
244 replacement_blob.annotations.push(replacement);
245 next_root = repo.set_context_blob(Some(&next_root), &target, &replacement_blob)?;
246
247 let (_, label) = target_label(&target);
248 let new_state = build_context_state(
249 &repo,
250 &head_state,
251 Some(next_root),
252 format!("context: supersede {label}"),
253 )?;
254 apply_new_state(&repo, &new_state)?;
255
256 if should_output_json(cli, None) {
257 println!(
258 "{}",
259 serde_json::json!({
260 "annotation_id": annotation_id,
261 "replacement_target": label,
262 "rewrite_pct": rewrite_pct,
263 "state": new_state.change_id.short(),
264 })
265 );
266 } else {
267 println!(
268 "Superseded annotation {} with a {}% rewrite",
269 annotation_id, rewrite_pct
270 );
271 }
272
273 Ok(())
274}
275
276pub async fn cmd_context_rm(
277 cli: &Cli,
278 path: Option<String>,
279 state: Option<String>,
280 scope: Option<String>,
281 all: bool,
282) -> Result<()> {
283 let repo = Repository::open(cli.repo.as_ref().unwrap_or(&std::env::current_dir()?))?;
284 let target = resolve_target(&repo, path, state)?;
285
286 let _lock = repo.locker().write().map_err(|e| anyhow::anyhow!("{e}"))?;
287 let head_state = resolve_state(&repo, None)?;
288 let Some(context_root) = &head_state.context else {
289 anyhow::bail!("No context annotations to remove");
290 };
291 if !all && scope.is_none() {
292 anyhow::bail!("Specify --scope to remove specific annotations, or --all to remove all");
293 }
294 let scope_filter = if all {
295 None
296 } else {
297 Some(parse_scope(scope.as_deref())?)
298 };
299
300 let new_context_root =
301 repo.remove_context_at_target(context_root, &target, scope_filter.as_ref())?;
302 let (_, label) = target_label(&target);
303 let new_state = build_context_state(
304 &repo,
305 &head_state,
306 new_context_root,
307 format!("context: remove annotation from {label}"),
308 )?;
309 apply_new_state(&repo, &new_state)?;
310
311 if should_output_json(cli, None) {
312 println!(
313 "{}",
314 serde_json::json!({
315 "target": label,
316 "removed": true,
317 "state": new_state.change_id.short(),
318 })
319 );
320 } else {
321 println!("Removed annotations from {label}");
322 }
323
324 Ok(())
325}