use crate::errors::AppError;
use crate::i18n;
use crate::output::{self, OutputFormat};
use crate::paths::AppPaths;
use crate::storage::connection::open_rw;
use crate::storage::entities;
use serde::Serialize;
#[derive(clap::Args)]
#[command(after_long_help = "EXAMPLES:\n \
# Preview how many 'mentions' relations would be removed\n \
sqlite-graphrag prune-relations --relation mentions --dry-run\n\n \
# Remove all 'mentions' relations without confirmation prompt\n \
sqlite-graphrag prune-relations --relation mentions --yes\n\n\
NOTE:\n \
This command permanently deletes relationships. Use --dry-run first.\n \
Entity degree counts are automatically recalculated after pruning.")]
pub struct PruneRelationsArgs {
#[arg(long, value_parser = crate::parsers::parse_relation, value_name = "RELATION")]
pub relation: String,
#[arg(long)]
pub namespace: Option<String>,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub yes: bool,
#[arg(long, value_enum, default_value = "json")]
pub format: OutputFormat,
#[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
pub json: bool,
#[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
pub db: Option<String>,
}
#[derive(Serialize)]
struct PruneRelationsResponse {
action: String,
relation: String,
count: usize,
entities_affected: usize,
namespace: String,
elapsed_ms: u64,
}
pub fn run(args: PruneRelationsArgs) -> Result<(), AppError> {
let inicio = std::time::Instant::now();
let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
let paths = AppPaths::resolve(args.db.as_deref())?;
crate::storage::connection::ensure_db_ready(&paths)?;
crate::parsers::warn_if_non_canonical(&args.relation);
let mut conn = open_rw(&paths.db)?;
if args.dry_run {
let count = entities::count_relationships_by_relation(&conn, &namespace, &args.relation)?;
output::emit_progress(&i18n::prune_dry_run(count, &args.relation));
let response = PruneRelationsResponse {
action: "dry_run".to_string(),
relation: args.relation.clone(),
count,
entities_affected: 0,
namespace: namespace.clone(),
elapsed_ms: inicio.elapsed().as_millis() as u64,
};
match args.format {
OutputFormat::Json => output::emit_json(&response)?,
OutputFormat::Text | OutputFormat::Markdown => {
output::emit_text(&format!(
"dry_run: {} '{}' relations would be removed [{}]",
response.count, response.relation, response.namespace
));
}
}
return Ok(());
}
if !args.yes {
output::emit_progress(&i18n::prune_requires_yes());
let count = entities::count_relationships_by_relation(&conn, &namespace, &args.relation)?;
let response = PruneRelationsResponse {
action: "aborted".to_string(),
relation: args.relation.clone(),
count,
entities_affected: 0,
namespace: namespace.clone(),
elapsed_ms: inicio.elapsed().as_millis() as u64,
};
match args.format {
OutputFormat::Json => output::emit_json(&response)?,
OutputFormat::Text | OutputFormat::Markdown => {
output::emit_text(&format!(
"aborted: {} '{}' relations would be removed; pass --yes to confirm [{}]",
response.count, response.relation, response.namespace
));
}
}
return Ok(());
}
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let (count, entity_ids) =
entities::delete_relationships_by_relation(&tx, &namespace, &args.relation)?;
tx.commit()?;
conn.execute_batch("ANALYZE relationships; ANALYZE memory_relationships;")?;
output::emit_progress(&i18n::relations_pruned(count, &args.relation, &namespace));
let response = PruneRelationsResponse {
action: "pruned".to_string(),
relation: args.relation.clone(),
count,
entities_affected: entity_ids.len(),
namespace: namespace.clone(),
elapsed_ms: inicio.elapsed().as_millis() as u64,
};
match args.format {
OutputFormat::Json => output::emit_json(&response)?,
OutputFormat::Text | OutputFormat::Markdown => {
output::emit_text(&format!(
"pruned: {} '{}' relations removed, {} entities affected [{}]",
response.count, response.relation, response.entities_affected, response.namespace
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prune_response_serializes_all_fields() {
let resp = PruneRelationsResponse {
action: "pruned".to_string(),
relation: "mentions".to_string(),
count: 3451,
entities_affected: 200,
namespace: "global".to_string(),
elapsed_ms: 42,
};
let json = serde_json::to_value(&resp).expect("serialization failed");
assert_eq!(json["action"], "pruned");
assert_eq!(json["relation"], "mentions");
assert_eq!(json["count"], 3451);
assert_eq!(json["entities_affected"], 200);
assert_eq!(json["namespace"], "global");
assert!(json["elapsed_ms"].is_number());
}
#[test]
fn prune_response_action_dry_run() {
let resp = PruneRelationsResponse {
action: "dry_run".to_string(),
relation: "mentions".to_string(),
count: 100,
entities_affected: 0,
namespace: "test".to_string(),
elapsed_ms: 5,
};
let json = serde_json::to_value(&resp).expect("serialization failed");
assert_eq!(json["action"], "dry_run");
assert_eq!(
json["entities_affected"], 0,
"dry_run must report zero entities_affected"
);
}
#[test]
fn prune_response_action_pruned() {
let resp = PruneRelationsResponse {
action: "pruned".to_string(),
relation: "uses".to_string(),
count: 50,
entities_affected: 10,
namespace: "my-project".to_string(),
elapsed_ms: 120,
};
let json = serde_json::to_value(&resp).expect("serialization failed");
assert_eq!(json["action"], "pruned");
assert!(json["count"].as_u64().unwrap() > 0);
assert!(json["entities_affected"].as_u64().unwrap() > 0);
}
#[test]
fn prune_response_zero_count_when_nothing_to_prune() {
let resp = PruneRelationsResponse {
action: "pruned".to_string(),
relation: "nonexistent".to_string(),
count: 0,
entities_affected: 0,
namespace: "global".to_string(),
elapsed_ms: 1,
};
let json = serde_json::to_value(&resp).expect("serialization failed");
assert_eq!(json["count"], 0);
assert_eq!(json["entities_affected"], 0);
}
}