sara-tasks 2.6.0

Sara — folder-aware task manager
use anyhow::Result;
use rusqlite::Connection;
use serde_json::{Value, json};
use std::collections::{HashMap, HashSet};

use super::cluster::consolidated::is_already_consolidated;
use super::cluster::eligibility::{deliberate_links, pair_key, projects_by_uuid, shares_project};
use super::cluster::split::split_component;
use super::cluster::tags::shared_tags;
use super::types::{Cluster, UnionFind};
use crate::infrastructure::db;
use crate::infrastructure::memory::graph::MemoryGraph;

pub fn reflect_value(conn: &Connection, min_weight: f64, max_cluster: usize) -> Result<Value> {
    let graph = MemoryGraph::build(conn)?;
    if graph.is_empty() {
        return Ok(json!({ "clusters": [], "count": 0 }));
    }

    let index: HashMap<String, usize> = graph
        .nodes
        .iter()
        .enumerate()
        .map(|(i, n)| (n.uuid.to_string(), i))
        .collect();

    let projects_by_uuid = projects_by_uuid(conn)?;

    let deliberate = deliberate_links(conn)?;

    let mut uf = UnionFind::new(graph.nodes.len());
    let mut eligible: Vec<(usize, usize, f64)> = Vec::new();
    for (a, b, w) in graph.edges() {
        if w < min_weight {
            continue;
        }
        let (ua, ub) = (a.to_string(), b.to_string());
        if !shares_project(&ua, &ub, &projects_by_uuid) && !deliberate.contains(&pair_key(&ua, &ub))
        {
            continue;
        }
        if let (Some(&ia), Some(&ib)) = (index.get(&ua), index.get(&ub)) {
            uf.union(ia, ib);
            eligible.push((ia, ib, w));
        }
    }

    let mut components: HashMap<usize, Vec<usize>> = HashMap::new();
    for i in 0..graph.nodes.len() {
        let root = uf.find(i);
        components.entry(root).or_default().push(i);
    }

    let derived_parents: HashMap<String, HashSet<String>> = graph
        .nodes
        .iter()
        .map(|n| {
            let parents: HashSet<String> = db::get_memory_links_from(conn, &n.uuid.to_string())
                .unwrap_or_default()
                .into_iter()
                .filter(|l| l.relation == "derived_from")
                .map(|l| l.to_uuid)
                .collect();
            (n.uuid.to_string(), parents)
        })
        .collect();

    let mut tags_by_uuid: HashMap<String, Vec<String>> = HashMap::new();
    for m in db::list_memories(conn)? {
        let tags: Vec<String> = m.tags.iter().map(|t| t.to_lowercase()).collect();
        tags_by_uuid.insert(m.uuid.to_string(), tags);
    }

    let uuids_of = |members: &[usize]| -> Vec<String> {
        members
            .iter()
            .map(|&i| graph.nodes[i].uuid.to_string())
            .collect()
    };

    let mut pieces: Vec<Vec<usize>> = Vec::new();
    for members in components.into_values() {
        if members.len() < 2 {
            continue;
        }
        if max_cluster > 0
            && members.len() > max_cluster
            && !is_already_consolidated(&uuids_of(&members), &derived_parents)
        {
            pieces.extend(split_component(members, &eligible, max_cluster));
        } else {
            pieces.push(members);
        }
    }

    let mut clusters: Vec<Cluster> = Vec::new();
    for member_idx in &pieces {
        if member_idx.len() < 2 {
            continue;
        }
        let uuids = uuids_of(member_idx);

        if is_already_consolidated(&uuids, &derived_parents) {
            continue;
        }

        let mut sorted: Vec<usize> = member_idx.clone();
        sorted.sort_by(|&a, &b| {
            graph.nodes[b]
                .strength
                .partial_cmp(&graph.nodes[a].strength)
                .unwrap_or(std::cmp::Ordering::Equal)
                .then(graph.nodes[a].label.cmp(&graph.nodes[b].label))
        });
        let suggested_canonical = graph.nodes[sorted[0]].label.clone();
        let members: Vec<String> = sorted
            .iter()
            .map(|&i| graph.nodes[i].label.clone())
            .collect();

        let shared_tags = shared_tags(&uuids, &tags_by_uuid);

        let strength_sum: f64 = member_idx.iter().map(|&i| graph.nodes[i].strength).sum();
        let score = member_idx.len() as f64 * 100.0 + strength_sum;

        clusters.push(Cluster {
            members,
            suggested_canonical,
            shared_tags,
            score,
        });
    }

    clusters.sort_by(|a, b| {
        b.score
            .partial_cmp(&a.score)
            .unwrap_or(std::cmp::Ordering::Equal)
            .then(a.suggested_canonical.cmp(&b.suggested_canonical))
    });

    let items: Vec<Value> = clusters.iter().map(cluster_json).collect();

    Ok(json!({
        "clusters": items,
        "count": items.len(),
    }))
}

fn cluster_json(c: &Cluster) -> Value {
    let proposed_links: Vec<Value> = c
        .members
        .iter()
        .filter(|m| **m != c.suggested_canonical)
        .map(|m| {
            json!({
                "from": m,
                "relation": "derived_from",
                "to": c.suggested_canonical,
            })
        })
        .collect();
    json!({
        "members": c.members,
        "suggested_canonical": c.suggested_canonical,
        "shared_tags": c.shared_tags,
        "proposed_links": proposed_links,
        "score": c.score,
    })
}