use std::path::Path;
use clap::{Args, Subcommand};
use crate::core::contradiction_detect::{
ConflictResolutionPlan, ConflictSurface, ContradictionDetectionConfig, PlannedResolutionAction,
assemble_conflict_surface,
};
use crate::db::DbConnection;
use crate::models::{DomainError, RESPONSE_SCHEMA_V2};
#[derive(Clone, Debug, Eq, PartialEq, Subcommand)]
pub enum ConflictCommand {
List(ConflictListArgs),
Explain(ConflictExplainArgs),
Cluster(ConflictClusterArgs),
Resolve(ConflictResolveArgs),
}
#[derive(Clone, Debug, Eq, PartialEq, Args)]
pub struct ConflictResolveArgs {
#[arg(value_name = "MEMORY_A")]
pub memory_a: String,
#[arg(value_name = "MEMORY_B")]
pub memory_b: String,
#[arg(long, value_name = "VERB")]
pub verb: String,
#[arg(long, value_name = "MEMORY_ID")]
pub keep: Option<String>,
#[arg(long, value_name = "TEXT")]
pub reason: Option<String>,
#[arg(long, value_name = "TAGS")]
pub scope_a_tags: Option<String>,
#[arg(long, value_name = "TAGS")]
pub scope_b_tags: Option<String>,
#[arg(long)]
pub apply: bool,
#[arg(long, value_name = "ACTOR")]
pub actor: Option<String>,
}
#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolutionActionResult {
pub action: String,
pub audit_ids: Vec<String>,
pub created_memory_id: Option<String>,
}
pub fn execute_conflict_resolution(
workspace: &Path,
database_path: &Path,
plan: &ConflictResolutionPlan,
reason: &str,
actor: Option<&str>,
) -> Result<Vec<ResolutionActionResult>, DomainError> {
use crate::core::decide::{DecideRecordOptions, decide_record};
use crate::core::memory::{
ExpireMemoryOptions, MemoryLinkMode, MemoryLinkOptions, MemoryTagsMode, MemoryTagsOptions,
expire_memory, update_memory_link, update_memory_tags,
};
use crate::db::{MemoryLinkRelation, MemoryLinkSource};
let mut results = Vec::with_capacity(plan.actions.len());
for action in &plan.actions {
let result = match action {
PlannedResolutionAction::RecordDecision {
topic,
chosen,
alternatives,
supersedes,
} => {
let report = decide_record(&DecideRecordOptions {
workspace_path: workspace,
database_path: Some(database_path),
topic,
chosen,
alternatives: alternatives.clone(),
rationale: reason,
revisit_by: None,
supersedes: supersedes.as_deref(),
dry_run: false,
actor,
now: None,
})?;
ResolutionActionResult {
action: "recordDecision".to_owned(),
audit_ids: [
report.memory_audit_id.clone(),
report.link_audit_id.clone(),
report.expire_audit_id.clone(),
]
.into_iter()
.flatten()
.collect(),
created_memory_id: Some(report.decision.memory_id.clone()),
}
}
PlannedResolutionAction::ExpireMemory { memory_id, reason } => {
let report = expire_memory(&ExpireMemoryOptions {
workspace_path: workspace,
database_path,
memory_id,
reason: Some(reason),
actor,
dry_run: false,
include_tombstoned: false,
})?;
ResolutionActionResult {
action: "expireMemory".to_owned(),
audit_ids: report.audit_id.into_iter().collect(),
created_memory_id: None,
}
}
PlannedResolutionAction::CreateLink { from, to, relation } => {
let relation =
MemoryLinkRelation::parse(relation).ok_or_else(|| DomainError::Usage {
message: format!("Unknown link relation {relation}."),
repair: None,
})?;
let report = update_memory_link(&MemoryLinkOptions {
workspace_path: workspace,
database_path,
memory_id: from,
mode: MemoryLinkMode::Create {
target_memory_id: to.clone(),
relation,
weight: 1.0,
confidence: 1.0,
directed: false,
evidence_count: 0,
source: MemoryLinkSource::Agent,
metadata_json: Some(
serde_json::json!({
"resolution": "both_valid",
"conflictId": plan.conflict_id,
})
.to_string(),
),
},
actor,
dry_run: false,
include_tombstoned: false,
})?;
ResolutionActionResult {
action: "createLink".to_owned(),
audit_ids: report.audit_id.into_iter().collect(),
created_memory_id: None,
}
}
PlannedResolutionAction::AddTags { memory_id, tags } => {
let report = update_memory_tags(&MemoryTagsOptions {
workspace_path: workspace,
database_path,
memory_id,
mode: MemoryTagsMode::Patch {
add: tags.clone(),
remove: Vec::new(),
},
actor,
dry_run: false,
include_tombstoned: false,
})?;
ResolutionActionResult {
action: "addTags".to_owned(),
audit_ids: report.audit_ids,
created_memory_id: None,
}
}
};
results.push(result);
}
Ok(results)
}
#[derive(Clone, Debug, Eq, PartialEq, Args)]
pub struct ConflictListArgs {}
#[derive(Clone, Debug, Eq, PartialEq, Args)]
pub struct ConflictExplainArgs {
#[arg(value_name = "MEMORY_ID")]
pub memory_id: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Args)]
pub struct ConflictClusterArgs {}
fn open_workspace_db(workspace: &Path) -> Result<DbConnection, DomainError> {
let database_path = workspace.join(".ee").join("ee.db");
if !database_path.exists() {
return Err(DomainError::Storage {
message: format!("No workspace database at {}.", database_path.display()),
repair: Some("Run `ee init --workspace . --json` first.".to_owned()),
});
}
DbConnection::open_file(&database_path).map_err(|error| DomainError::Storage {
message: format!("Failed to open workspace database: {error}"),
repair: Some("Run `ee doctor --workspace . --json`.".to_owned()),
})
}
pub fn build_conflict_surface(workspace: &Path) -> Result<ConflictSurface, DomainError> {
let connection = open_workspace_db(workspace)?;
Ok(assemble_conflict_surface(
&connection,
ContradictionDetectionConfig::default(),
))
}
pub fn build_conflict_surface_for_memory(
workspace: &Path,
memory_id: &str,
) -> Result<ConflictSurface, DomainError> {
Ok(build_conflict_surface(workspace)?.focused_on(memory_id))
}
#[must_use]
pub fn render_conflict_json(surface: &ConflictSurface) -> String {
serde_json::json!({
"schema": RESPONSE_SCHEMA_V2,
"success": true,
"data": surface,
"degraded": [],
})
.to_string()
}
fn truncate_body(content: &str) -> String {
const MAX: usize = 72;
let oneline = content.replace('\n', " ");
if oneline.chars().count() <= MAX {
oneline
} else {
let kept: String = oneline.chars().take(MAX).collect();
format!("{kept}…")
}
}
#[must_use]
pub fn render_conflict_human(surface: &ConflictSurface, command: &ConflictCommand) -> String {
let mut out = String::new();
match command {
ConflictCommand::Cluster(_) => {
out.push_str(&format!(
"Contradiction clusters: {}\n",
surface.clusters.len()
));
for cluster in &surface.clusters {
out.push_str(&format!(
" - cluster {} ({:?}, size {}): centrality {}, load-bearing {}m, score {:.3}\n",
cluster.louvain_id,
cluster.severity,
cluster.size,
cluster.centrality,
cluster.load_bearing_milli,
cluster.rank_score,
));
}
}
_ => {
out.push_str(&format!("Conflicting pairs: {}\n", surface.pairs.len()));
for pair in &surface.pairs {
out.push_str(&format!(
" - {} [{}] prefers side {} ({})\n A {}: {}\n B {}: {}\n",
pair.conflict_id,
pair.signal,
pair.preferred_side,
pair.preferred_reason,
pair.memory_a.id,
truncate_body(&pair.memory_a.content),
pair.memory_b.id,
truncate_body(&pair.memory_b.content),
));
}
}
}
if !surface.deferred_signals.is_empty() {
out.push_str(&format!(
"Deferred signal kinds (not yet gathered): {}\n",
surface.deferred_signals.join(", ")
));
}
if !surface.degraded.is_empty() {
out.push_str(&format!("Degraded: {}\n", surface.degraded.join("; ")));
}
out
}
#[cfg(test)]
mod tests {
use super::{
ConflictCommand, ConflictListArgs, render_conflict_human, render_conflict_json,
truncate_body,
};
use crate::core::contradiction_detect::{CONFLICT_SURFACE_SCHEMA_V1, ConflictSurface};
fn empty_surface() -> ConflictSurface {
ConflictSurface {
schema: CONFLICT_SURFACE_SCHEMA_V1,
pairs: Vec::new(),
clusters: Vec::new(),
explicit_edge_count: 0,
gathered_signals: vec!["contradiction_link".to_owned()],
deferred_signals: vec!["validity_window_overlap".to_owned()],
fuzzy_near_conflict_skipped: false,
degraded: Vec::new(),
}
}
#[test]
fn human_render_reports_zero_pairs_and_deferred_kinds() {
let surface = empty_surface();
let text = render_conflict_human(&surface, &ConflictCommand::List(ConflictListArgs {}));
assert!(text.contains("Conflicting pairs: 0"));
assert!(text.contains("validity_window_overlap"));
}
#[test]
fn truncate_body_collapses_newlines_and_caps_length() {
let body = "line one\nline two";
assert_eq!(truncate_body(body), "line one line two");
let long = "x".repeat(200);
let truncated = truncate_body(&long);
assert!(truncated.chars().count() <= 73, "capped with ellipsis");
assert!(truncated.ends_with('…'));
}
#[test]
fn conflict_json_envelope_includes_clean_degraded_array() {
let surface = empty_surface();
let raw = render_conflict_json(&surface);
let envelope: serde_json::Value =
serde_json::from_str(&raw).expect("conflict json envelope");
assert_eq!(envelope["schema"], crate::models::RESPONSE_SCHEMA_V2);
assert_eq!(envelope["success"], true);
assert_eq!(envelope["data"]["schema"], CONFLICT_SURFACE_SCHEMA_V1);
assert_eq!(envelope["degraded"], serde_json::json!([]));
}
}