use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use aristo_core::canon::{
cache::{AcceptedMatch, CacheEntry, CanonMatchesFile, Disposition, PendingMatch},
rewrite::{compute_rewrite, AcceptRewriteRequest, AttributeRewrite, RewriteError},
};
use aristo_core::index::{AnnotationId, ArtaId, BindingState, IndexEntry, IndexFile};
use aristo_core::walk::extract_from_source;
use crate::commands::index::workspace_or_error;
use crate::{CliError, CliResult, Workspace};
pub(crate) fn run(annotation_id: &str, canon_id: &str) -> CliResult<()> {
let ws = workspace_or_error()?;
let now = now_rfc3339();
apply_acceptance(&ws, annotation_id, canon_id, &now)
}
pub(crate) fn apply_acceptance(
ws: &Workspace,
annotation_id_raw: &str,
canon_id: &str,
now: &str,
) -> CliResult<()> {
let ann_id = AnnotationId::parse(annotation_id_raw).map_err(|e| CliError::Other {
message: format!(
"annotation id `{annotation_id_raw}` is not valid ({e}).\n\
Run `aristo list` to see indexed ids."
),
exit_code: 2,
})?;
let cache_path = ws.canon_matches_path();
let mut cache = CanonMatchesFile::read(&cache_path).map_err(CliError::Io)?;
let pending = locate_pending(&cache, &ann_id, canon_id)?;
let index_path = ws.index_path();
let mut index = read_index(&index_path)?;
let entry = index.entries.get(&ann_id).ok_or_else(|| CliError::Other {
message: format!(
"annotation id `{}` not found in .aristo/index.toml.\n\
Run `aristo stamp` if you have just edited source.",
ann_id.as_str()
),
exit_code: 1,
})?;
let intent = match entry {
IndexEntry::Intent(e) => e.clone(),
IndexEntry::Assume(_) => {
return Err(CliError::Other {
message: format!(
"annotation id `{}` is an `assume`, not an `intent`. Canon \
matches only apply to intents — see the §13 design archive.",
ann_id.as_str()
),
exit_code: 1,
});
}
};
if !matches!(intent.binding, BindingState::Local) {
return Err(CliError::Other {
message: format!(
"annotation `{}` is already canon-bound. Run `aristo canon unbind \
{}` first if you want to re-bind it.",
ann_id.as_str(),
ann_id.as_str()
),
exit_code: 1,
});
}
let linked: ArtaId = match &pending.linked {
Some(s) => ArtaId::parse(s).map_err(|e| CliError::Other {
message: format!(
"pending match's `linked` field `{s}` is not a valid arta_* id ({e}). \
The canon API returned a malformed identifier; rerun \
`aristo stamp` to refresh, or report the bug.",
),
exit_code: 1,
})?,
None => aristo_core::canon::synthesize_phase1_linked(&pending.canon_id, &pending.version),
};
let linked_str = linked.as_str().to_string();
let prefixed_str = match pending.prefix_tier {
aristo_core::canon::PrefixTier::Aristos => format!("aristos:{canon_id}"),
aristo_core::canon::PrefixTier::Kanon => format!("kanon:{canon_id}"),
};
let prefixed_id = AnnotationId::parse(&prefixed_str).map_err(|e| CliError::Other {
message: format!("internal: failed to build prefixed annotation id `{prefixed_str}`: {e}"),
exit_code: 1,
})?;
if prefixed_id != ann_id && index.entries.contains_key(&prefixed_id) {
return Err(CliError::Other {
message: format!(
"id `{}` is already in the index — would collide with the canon \
binding for `{}`. Delete the conflicting annotation first.",
prefixed_str,
ann_id.as_str()
),
exit_code: 1,
});
}
let item_line = parse_line_from_site(&intent.site).ok_or_else(|| CliError::Other {
message: format!(
"internal: cannot parse line number from index entry's site `{}` \
— re-run `aristo stamp` to refresh.",
intent.site
),
exit_code: 1,
})?;
let src_path = ws.root.join(&intent.file);
let source = fs::read_to_string(&src_path).map_err(|e| CliError::Other {
message: format!(
"cannot read `{}`: {e}\n\
hint: the index references this file; run `aristo stamp` if you \
have moved or removed it.",
src_path.display()
),
exit_code: 1,
})?;
let rewrite_request = AcceptRewriteRequest {
item_line,
canon_id: canon_id.to_string(),
canonical_text: pending.canonical_text.clone(),
prefix_tier: pending.prefix_tier,
};
let rewrite = compute_rewrite(&source, &rewrite_request).map_err(rewrite_error_to_cli)?;
let new_source = splice_source(&source, &rewrite);
atomic_write_bytes(&src_path, new_source.as_bytes())?;
let fresh = extract_from_source(&new_source).map_err(|e| CliError::Other {
message: format!(
"internal: failed to re-walk `{}` after rewrite: {e}\n\
The source file may be unparseable after the rewrite; report \
this as a canon-accept bug.",
src_path.display()
),
exit_code: 1,
})?;
let mut line_by_site: BTreeMap<String, usize> = BTreeMap::new();
for ann in &fresh {
line_by_site.insert(ann.site.clone(), ann.line);
}
let mut new_intent = intent.clone();
new_intent.text = pending.canonical_text.clone();
new_intent.binding = BindingState::Bound { linked };
new_intent.last_critiqued_at_text_hash = None;
new_intent.last_critique_finding_count = None;
new_intent.site = refresh_site(&new_intent.site, &line_by_site);
index.entries.remove(&ann_id);
index
.entries
.insert(prefixed_id.clone(), IndexEntry::Intent(new_intent));
for (_id, entry) in index.entries.iter_mut() {
let same_file = entry_file(entry) == intent.file;
if !same_file {
continue;
}
let new_site = refresh_site(entry_site(entry), &line_by_site);
set_entry_site(entry, new_site);
}
let index_toml = toml::to_string_pretty(&index).map_err(|e| CliError::Other {
message: format!("serializing .aristo/index.toml: {e}"),
exit_code: 1,
})?;
atomic_write_bytes(&index_path, index_toml.as_bytes())?;
let accepted = AcceptedMatch {
canon_id: pending.canon_id.clone(),
version: pending.version.clone(),
canonical_text: pending.canonical_text.clone(),
canon_version: pending.canon_version.clone(),
confidence: pending.confidence,
prefix_tier: pending.prefix_tier,
backed_by: pending.backed_by.clone(),
linked: Some(linked_str.clone()),
accepted_at: now.to_string(),
bound_at: now.to_string(),
};
remove_pending(&mut cache, &ann_id, canon_id);
let bound_entry = cache
.entries
.entry(prefixed_id.clone())
.or_insert_with(|| CacheEntry {
last_match_text_hash: intent.text_hash.as_str().to_string(),
canon_fetched_at: now.to_string(),
pending_matches: vec![],
accepted_matches: vec![],
rejected_matches: vec![],
});
bound_entry.accepted_matches.push(accepted);
cache.write_atomic(&cache_path).map_err(CliError::Io)?;
let _ = linked_str;
println!(
"ok: accepted canon match for `{}` → `{}`.",
ann_id.as_str(),
prefixed_id.as_str(),
);
Ok(())
}
fn refresh_site(old: &str, line_by_site: &BTreeMap<String, usize>) -> String {
let stripped = match old.rfind(" (line ") {
Some(idx) => &old[..idx],
None => old,
};
match line_by_site.get(stripped) {
Some(line) => format!("{stripped} (line {line})"),
None => old.to_string(),
}
}
fn set_entry_site(entry: &mut IndexEntry, site: String) {
match entry {
IndexEntry::Intent(e) => e.site = site,
IndexEntry::Assume(e) => e.site = site,
}
}
fn entry_site(entry: &IndexEntry) -> &str {
match entry {
IndexEntry::Intent(e) => &e.site,
IndexEntry::Assume(e) => &e.site,
}
}
fn entry_file(entry: &IndexEntry) -> &str {
match entry {
IndexEntry::Intent(e) => &e.file,
IndexEntry::Assume(e) => &e.file,
}
}
fn locate_pending(
cache: &CanonMatchesFile,
ann_id: &AnnotationId,
canon_id: &str,
) -> CliResult<PendingMatch> {
let entry = cache.entries.get(ann_id).ok_or_else(|| CliError::Other {
message: format!(
"no pending canon matches for `{}` in .aristo/canon-matches.toml.\n\
hint: run `aristo stamp` to refresh.",
ann_id.as_str()
),
exit_code: 1,
})?;
let mut candidates: Vec<&PendingMatch> = entry
.pending_matches
.iter()
.filter(|m| m.canon_id == canon_id)
.collect();
candidates.sort_by(|a, b| {
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
});
let pending = candidates.first().ok_or_else(|| CliError::Other {
message: format!(
"no pending canon match `{canon_id}` for annotation `{}` in \
.aristo/canon-matches.toml.\n\
hint: list pending matches with `aristo critique --apply-findings`.",
ann_id.as_str()
),
exit_code: 1,
})?;
if matches!(pending.disposition, Disposition::Accepted) {
}
Ok((*pending).clone())
}
fn remove_pending(cache: &mut CanonMatchesFile, ann_id: &AnnotationId, canon_id: &str) {
if let Some(entry) = cache.entries.get_mut(ann_id) {
entry.pending_matches.retain(|m| m.canon_id != canon_id);
}
}
fn rewrite_error_to_cli(e: RewriteError) -> CliError {
CliError::Other {
message: format!("source rewrite failed: {e}"),
exit_code: 1,
}
}
fn parse_line_from_site(site: &str) -> Option<usize> {
let open = site.rfind("(line ")?;
let after = &site[open + "(line ".len()..];
let close = after.rfind(')')?;
after[..close].trim().parse().ok()
}
fn splice_source(source: &str, rewrite: &AttributeRewrite) -> String {
let mut bytes = source.as_bytes().to_vec();
bytes.splice(
rewrite.byte_start..rewrite.byte_end,
rewrite.replacement.as_bytes().iter().copied(),
);
String::from_utf8(bytes).expect("rewrite preserves utf-8")
}
fn atomic_write_bytes(target: &Path, content: &[u8]) -> CliResult<()> {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(CliError::Io)?;
}
let tmp_name = match target.file_name() {
Some(name) => {
let mut s = name.to_os_string();
s.push(".aristo-tmp");
s
}
None => {
return Err(CliError::Other {
message: format!(
"canon accept: cannot atomic-write `{}` — path has no file name",
target.display()
),
exit_code: 1,
});
}
};
let tmp = target.with_file_name(tmp_name);
fs::write(&tmp, content).map_err(CliError::Io)?;
fs::rename(&tmp, target).map_err(CliError::Io)?;
Ok(())
}
fn read_index(path: &Path) -> CliResult<IndexFile> {
if !path.is_file() {
return Err(CliError::Other {
message: format!(
"no .aristo/index.toml at {}\n\
hint: run `aristo stamp` to build one",
path.display()
),
exit_code: 2,
});
}
let text = fs::read_to_string(path).map_err(CliError::Io)?;
toml::from_str(&text).map_err(|e| CliError::Other {
message: format!("parsing {}: {e}", path.display()),
exit_code: 1,
})
}
fn now_rfc3339() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock is post-1970")
.as_secs();
crate::session::id_gen::format_rfc3339(secs)
}