use anyhow::{Result, anyhow};
use chrono::Utc;
use heddle_core::{
ContextContentPlanError, ContextRmPlanError, count_active_annotations, next_annotation_tags,
plan_annotation_content_source, plan_context_rm, supersede_reuses_original_scope,
supersede_reuses_original_target,
};
use objects::{
lock::RepositoryLockExt,
object::{Annotation, ContextBlob},
};
use repo::compute_rewrite_pct;
use super::{
apply_new_state, build_context_state, compute_source_hash, parse_kind, parse_scope,
read_annotation_content, resolve_scope_at_target, resolve_state, resolve_target, target_label,
};
use crate::{
cli::{
Cli,
commands::{RecoveryAdvice, snapshot::resolve_attribution},
should_output_json,
},
config::UserConfig,
};
fn content_source_advice(err: ContextContentPlanError) -> RecoveryAdvice {
match err {
ContextContentPlanError::Required => RecoveryAdvice::invalid_usage(
err.kind(),
"Provide annotation content with -m or --file",
"Pass `-m <text>` or `--file <path>` with annotation content.",
"heddle context set --path <path> -m \"...\"",
),
}
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_context_set(
cli: &Cli,
path: Option<String>,
state: Option<String>,
scope: Option<String>,
kind: String,
tags: Vec<String>,
message: Option<String>,
file: Option<std::path::PathBuf>,
) -> Result<()> {
let repo = cli.open_repo()?;
let target = resolve_target(&repo, path, state)?;
let scope = parse_scope(scope.as_deref())?;
target.validate_scope(&scope)?;
let kind = parse_kind(Some(&kind))?;
plan_annotation_content_source(message.is_some(), file.is_some())
.map_err(|err| anyhow!(content_source_advice(err)))?;
let content = read_annotation_content(message, file)?;
let _lock = repo.locker().write().map_err(|e| anyhow::anyhow!("{e}"))?;
let head_state = resolve_state(&repo, None)?;
let scope = resolve_scope_at_target(&repo, &target, scope)?;
let source_hash = compute_source_hash(&repo, &target, &scope);
let user_config = UserConfig::load_default()?;
let attribution = resolve_attribution(&repo, &user_config)?;
let annotation = Annotation::new(
scope,
kind,
content,
tags,
attribution.to_string(),
Utc::now().timestamp(),
source_hash,
Some(head_state.change_id),
);
let mut blob = match &head_state.context {
Some(root) => repo
.get_context_blob(root, &target)?
.unwrap_or_else(|| ContextBlob::new(vec![])),
None => ContextBlob::new(vec![]),
};
blob.annotations.push(annotation);
let new_context_root = repo.set_context_blob(head_state.context.as_ref(), &target, &blob)?;
let (_, label) = target_label(&target);
let new_state = build_context_state(
&repo,
&head_state,
Some(new_context_root),
format!("context: annotate {label}"),
)?;
apply_new_state(&repo, &new_state)?;
if should_output_json(cli, None) {
println!(
"{}",
serde_json::json!({
"output_kind": "context_set",
"target": label,
"annotations": blob.annotations.len(),
"state": new_state.change_id.short(),
})
);
} else {
let active = count_active_annotations(&blob.annotations);
println!(
"Annotated {} ({} active annotation{})",
label,
active,
if blob.annotations.len() == 1 { "" } else { "s" }
);
}
Ok(())
}
pub async fn cmd_context_edit(
cli: &Cli,
annotation_id: String,
kind: Option<String>,
tags: Vec<String>,
message: Option<String>,
file: Option<std::path::PathBuf>,
) -> Result<()> {
let repo = cli.open_repo()?;
plan_annotation_content_source(message.is_some(), file.is_some())
.map_err(|err| anyhow!(content_source_advice(err)))?;
let content = read_annotation_content(message, file)?;
let _lock = repo.locker().write().map_err(|e| anyhow::anyhow!("{e}"))?;
let head_state = resolve_state(&repo, None)?;
let context_root = head_state
.context
.as_ref()
.ok_or_else(|| anyhow::anyhow!(RecoveryAdvice::context_empty()))?;
let (target, mut blob, index) = repo
.find_annotation(context_root, &annotation_id)?
.ok_or_else(|| anyhow::anyhow!(RecoveryAdvice::annotation_not_found(&annotation_id)))?;
let annotation = blob
.annotations
.get_mut(index)
.ok_or_else(|| anyhow::anyhow!("Annotation index out of range"))?;
let current = annotation.current_revision().cloned().unwrap();
let next_kind = match kind.as_deref() {
Some(kind) => parse_kind(Some(kind))?,
None => current.kind,
};
let next_tags = next_annotation_tags(¤t.tags, tags);
annotation.scope = resolve_scope_at_target(&repo, &target, annotation.scope.clone())?;
let source_hash = compute_source_hash(&repo, &target, &annotation.scope);
let user_config = UserConfig::load_default()?;
let attribution = resolve_attribution(&repo, &user_config)?;
annotation.revise(
next_kind,
content,
next_tags,
attribution.to_string(),
Utc::now().timestamp(),
source_hash,
Some(head_state.change_id),
);
let revision_count = annotation.revisions.len();
let _ = annotation;
let new_context_root = repo.set_context_blob(Some(context_root), &target, &blob)?;
let (_, label) = target_label(&target);
let new_state = build_context_state(
&repo,
&head_state,
Some(new_context_root),
format!("context: revise {label}"),
)?;
apply_new_state(&repo, &new_state)?;
if should_output_json(cli, None) {
println!(
"{}",
serde_json::json!({
"output_kind": "context_edit",
"annotation_id": annotation_id,
"state": new_state.change_id.short(),
"revision_count": revision_count,
})
);
} else {
println!("Revised annotation {}", annotation_id);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_context_supersede(
cli: &Cli,
annotation_id: String,
path: Option<String>,
state: Option<String>,
scope: Option<String>,
kind: String,
tags: Vec<String>,
message: Option<String>,
file: Option<std::path::PathBuf>,
) -> Result<()> {
let repo = cli.open_repo()?;
plan_annotation_content_source(message.is_some(), file.is_some())
.map_err(|err| anyhow!(content_source_advice(err)))?;
let content = read_annotation_content(message, file)?;
let _lock = repo.locker().write().map_err(|e| anyhow::anyhow!("{e}"))?;
let head_state = resolve_state(&repo, None)?;
let context_root = head_state
.context
.as_ref()
.ok_or_else(|| anyhow::anyhow!(RecoveryAdvice::context_empty()))?;
let (original_target, mut original_blob, index) = repo
.find_annotation(context_root, &annotation_id)?
.ok_or_else(|| anyhow::anyhow!(RecoveryAdvice::annotation_not_found(&annotation_id)))?;
let original_annotation = original_blob.annotations[index].clone();
let original_revision = original_annotation.current_revision().cloned().unwrap();
let target = if supersede_reuses_original_target(path.as_deref(), state.as_deref()) {
original_target.clone()
} else {
resolve_target(&repo, path, state)?
};
let replacement_scope = if supersede_reuses_original_scope(scope.as_deref()) {
original_annotation.scope.clone()
} else {
parse_scope(scope.as_deref())?
};
target.validate_scope(&replacement_scope)?;
let replacement_scope = resolve_scope_at_target(&repo, &target, replacement_scope)?;
let kind = parse_kind(Some(&kind))?;
let source_hash = compute_source_hash(&repo, &target, &replacement_scope);
let rewrite_pct = compute_rewrite_pct(&original_revision.content, &content);
let user_config = UserConfig::load_default()?;
let attribution = resolve_attribution(&repo, &user_config)?;
let mut replacement = Annotation::new(
replacement_scope,
kind,
content,
tags,
attribution.to_string(),
Utc::now().timestamp(),
source_hash,
Some(head_state.change_id),
);
replacement.supersedes_annotation_id = Some(annotation_id.clone());
replacement.supersedes_rewrite_pct = Some(rewrite_pct);
original_blob.annotations[index].mark_superseded();
let mut next_root =
repo.set_context_blob(Some(context_root), &original_target, &original_blob)?;
let mut replacement_blob = if target == original_target {
original_blob
} else {
repo.get_context_blob(&next_root, &target)?
.unwrap_or_else(|| ContextBlob::new(vec![]))
};
replacement_blob.annotations.push(replacement);
next_root = repo.set_context_blob(Some(&next_root), &target, &replacement_blob)?;
let (_, label) = target_label(&target);
let new_state = build_context_state(
&repo,
&head_state,
Some(next_root),
format!("context: supersede {label}"),
)?;
apply_new_state(&repo, &new_state)?;
if should_output_json(cli, None) {
println!(
"{}",
serde_json::json!({
"output_kind": "context_supersede",
"annotation_id": annotation_id,
"replacement_target": label,
"rewrite_pct": rewrite_pct,
"state": new_state.change_id.short(),
})
);
} else {
println!(
"Superseded annotation {} with a {}% rewrite",
annotation_id, rewrite_pct
);
}
Ok(())
}
pub async fn cmd_context_rm(
cli: &Cli,
path: Option<String>,
state: Option<String>,
scope: Option<String>,
all: bool,
) -> Result<()> {
let repo = cli.open_repo()?;
let target = resolve_target(&repo, path, state)?;
let _lock = repo.locker().write().map_err(|e| anyhow::anyhow!("{e}"))?;
let head_state = resolve_state(&repo, None)?;
let Some(context_root) = &head_state.context else {
return Err(anyhow::anyhow!(RecoveryAdvice::invalid_usage(
"context_remove_empty",
"No context annotations to remove",
"Inspect context with `heddle context list`, then remove an existing annotation scope.",
"heddle context list",
)));
};
plan_context_rm(all, scope.is_some()).map_err(|err| match err {
ContextRmPlanError::ScopeRequired => anyhow!(RecoveryAdvice::invalid_usage(
err.kind(),
"Specify --scope to remove specific annotations, or --all to remove all",
"Pass `--scope <scope>` to remove one scope, or `--all` to remove all annotations at the target.",
"heddle context rm --path <path> --scope file",
)),
})?;
let scope_filter = if all {
None
} else {
Some(parse_scope(scope.as_deref())?)
};
let new_context_root =
repo.remove_context_at_target(context_root, &target, scope_filter.as_ref())?;
let (_, label) = target_label(&target);
let new_state = build_context_state(
&repo,
&head_state,
new_context_root,
format!("context: remove annotation from {label}"),
)?;
apply_new_state(&repo, &new_state)?;
if should_output_json(cli, None) {
println!(
"{}",
serde_json::json!({
"output_kind": "context_rm",
"target": label,
"removed": true,
"state": new_state.change_id.short(),
})
);
} else {
println!("Removed annotations from {label}");
}
Ok(())
}