Skip to main content

cli/cli/commands/context/
context_mutate.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Context mutation commands: set, edit, supersede, rm.
3
4use 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/// Set a context annotation on a file path or state target.
22#[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    // Eagerly resolve symbol scopes against the worktree so the annotation
43    // carries `resolved_lines` from the moment of creation. Without this, the
44    // staleness check returns SymbolMissing on the very first read and the
45    // chip never renders.
46    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}