git-slop 0.16.4

A deterministic repository token-defragmenter for humans and AI agents.
Documentation
fn merge_missing_fields(target: &mut Value, source: &Value) {
    let (Some(target), Some(source)) = (target.as_object_mut(), source.as_object()) else {
        return;
    };
    for (key, value) in source {
        if !target.contains_key(key) || target.get(key).is_some_and(Value::is_null) {
            target.insert(key.clone(), value.clone());
        }
    }
}

fn deduplicate_clusters(values: Vec<Value>) -> Vec<Value> {
    let mut indexed = std::collections::BTreeMap::<String, Value>::new();
    let mut anonymous = Vec::new();
    for item in values {
        let Some(id) = item.get("id").and_then(Value::as_str).map(ToOwned::to_owned) else {
            anonymous.push(item);
            continue;
        };
        match indexed.entry(id) {
            std::collections::btree_map::Entry::Vacant(entry) => {
                entry.insert(item);
            }
            std::collections::btree_map::Entry::Occupied(mut entry) => {
                let existing_is_candidate = entry
                    .get()
                    .get("kind")
                    .and_then(Value::as_str)
                    == Some("consolidation_candidate");
                let incoming_is_candidate = item.get("kind").and_then(Value::as_str)
                    == Some("consolidation_candidate");
                if existing_is_candidate && !incoming_is_candidate {
                    let mut preferred = item;
                    merge_missing_fields(&mut preferred, entry.get());
                    entry.insert(preferred);
                } else {
                    merge_missing_fields(entry.get_mut(), &item);
                }
            }
        }
    }
    indexed.into_values().chain(anonymous).collect()
}

fn evidence_score(item: &Value) -> f64 {
    item.get("evidence_score")
        .or_else(|| item.get("evidence_lower_bound"))
        .and_then(Value::as_f64)
        .unwrap_or_default()
}

fn rank_list_values(kind: &str, values: &mut [Value]) {
    match kind {
        "relationships" | "clusters" => values.sort_by(|left, right| {
            evidence_score(right)
                .partial_cmp(&evidence_score(left))
                .unwrap_or(std::cmp::Ordering::Equal)
                .then_with(|| {
                    left.get("id")
                        .and_then(Value::as_str)
                        .cmp(&right.get("id").and_then(Value::as_str))
                })
        }),
        "profiles" => values.sort_by(|left, right| {
            left.get("name")
                .and_then(Value::as_str)
                .cmp(&right.get("name").and_then(Value::as_str))
        }),
        _ => {}
    }
}

fn render_findings_table(values: &[Value], output: &ListOutputArgs) {
    let terminal_width = std::env::var("COLUMNS")
        .ok()
        .and_then(|value| value.parse::<usize>().ok())
        .unwrap_or(100);
    let path_width = if output.no_truncate {
        values
            .iter()
            .filter_map(|item| item.get("path").and_then(Value::as_str))
            .map(str::len)
            .max()
            .unwrap_or(24)
            .max(24)
    } else if output.wide {
        80
    } else {
        terminal_width.saturating_sub(59).clamp(24, 48)
    };
    println!(
        "{:<path_width$}  {:<16}  {:<10}  {:<10}  {:<10}  {:>7}",
        "PATH", "PROFILE", "SEVERITY", "CONTEXT", "SLOP", "SCORE"
    );
    println!(
        "{:-<path_width$}  {:-<16}  {:-<10}  {:-<10}  {:-<10}  {:-<7}",
        "", "", "", "", "", ""
    );
    for item in values {
        let label = item.get("path").and_then(Value::as_str).unwrap_or("-");
        let profile = item.get("profile").and_then(Value::as_str).unwrap_or("-");
        let severity = item.get("severity").and_then(Value::as_str).unwrap_or("-");
        let context = item
            .get("context_band")
            .and_then(Value::as_str)
            .unwrap_or("-");
        let slop = item.get("slop_band").and_then(Value::as_str).unwrap_or("-");
        let score = item
            .get("slop_score")
            .and_then(Value::as_f64)
            .map_or_else(|| "-".to_string(), |value| format!("{value:.3}"));
        println!(
            "{:<path_width$}  {:<16}  {:<10}  {:<10}  {:<10}  {:>7}",
            terminal_field(label, path_width, output.no_truncate),
            terminal_field(profile, 16, output.no_truncate),
            terminal_field(severity, 10, output.no_truncate),
            terminal_field(context, 10, output.no_truncate),
            terminal_field(slop, 10, output.no_truncate),
            score
        );
    }
}

fn render_relationships_table(values: &[Value], output: &ListOutputArgs) {
    println!(
        "{:<34}  {:<24}  {:>9}  {:>7}  {:>10}",
        "RELATIONSHIP", "KIND", "EVIDENCE", "SUPPORT", "CONFIDENCE"
    );
    println!("{:-<34}  {:-<24}  {:-<9}  {:-<7}  {:-<10}", "", "", "", "", "");
    let endpoint_width = if output.no_truncate || output.wide { 72 } else { 46 };
    for item in values {
        let id = item.get("id").and_then(Value::as_str).unwrap_or("-");
        let kind = item.get("kind").and_then(Value::as_str).unwrap_or("-");
        let support = item.get("support_count").and_then(Value::as_u64).unwrap_or_default();
        let confidence = item
            .get("confidence")
            .and_then(Value::as_str)
            .unwrap_or("-");
        println!(
            "{:<34}  {:<24}  {:>9.3}  {:>7}  {:>10}",
            terminal_field(id, 34, output.no_truncate),
            terminal_field(kind, 24, output.no_truncate),
            evidence_score(item),
            support,
            terminal_field(confidence, 10, output.no_truncate)
        );
        let source = item.get("source_path").and_then(Value::as_str).unwrap_or("-");
        let target = item.get("target_path").and_then(Value::as_str).unwrap_or("-");
        println!(
            "  {} → {}",
            terminal_field(source, endpoint_width, output.no_truncate),
            terminal_field(target, endpoint_width, output.no_truncate)
        );
    }
}

fn render_clusters_table(values: &[Value], output: &ListOutputArgs) {
    println!(
        "{:<34}  {:<26}  {:>7}  {:>9}  {:<24}",
        "CLUSTER", "KIND", "MEMBERS", "EVIDENCE", "CANDIDATE"
    );
    println!("{:-<34}  {:-<26}  {:-<7}  {:-<9}  {:-<24}", "", "", "", "", "");
    let member_width = if output.no_truncate || output.wide { 100 } else { 86 };
    for item in values {
        let id = item.get("id").and_then(Value::as_str).unwrap_or("-");
        let kind = item.get("kind").and_then(Value::as_str).unwrap_or("-");
        let members = item
            .get("member_count")
            .and_then(Value::as_u64)
            .or_else(|| item.get("member_paths").and_then(Value::as_array).map(|v| v.len() as u64))
            .unwrap_or_default();
        let candidate = item
            .get("candidate_type")
            .and_then(Value::as_str)
            .unwrap_or("-");
        println!(
            "{:<34}  {:<26}  {:>7}  {:>9.3}  {:<24}",
            terminal_field(id, 34, output.no_truncate),
            terminal_field(kind, 26, output.no_truncate),
            members,
            evidence_score(item),
            terminal_field(candidate, 24, output.no_truncate)
        );
        let member_paths = item
            .get("member_paths")
            .and_then(Value::as_array)
            .into_iter()
            .flatten()
            .filter_map(Value::as_str)
            .collect::<Vec<_>>()
            .join(", ");
        println!("  {}", terminal_field(&member_paths, member_width, output.no_truncate));
    }
}

fn render_profiles_table(values: &[Value]) {
    println!(
        "{:<20}  {:>7}  {:>12}  {:>10}  {:>10}  {:>10}  {:>10}",
        "PROFILE", "FILES", "TOKENS", "LINES", "CODE", "COMMENTS", "BLANKS"
    );
    println!("{:-<20}  {:-<7}  {:-<12}  {:-<10}  {:-<10}  {:-<10}  {:-<10}", "", "", "", "", "", "", "");
    for item in values {
        let totals = item.get("totals").unwrap_or(&Value::Null);
        let total = |key: &str| totals.get(key).and_then(Value::as_u64).unwrap_or_default();
        println!(
            "{:<20}  {:>7}  {:>12}  {:>10}  {:>10}  {:>10}  {:>10}",
            terminal_field(item.get("name").and_then(Value::as_str).unwrap_or("-"), 20, false),
            total("files"),
            total("tokens"),
            total("lines"),
            total("code"),
            total("comments"),
            total("blanks")
        );
    }
}