deciduous 0.15.0

Decision graph tooling for AI-assisted development. Track every goal, decision, and outcome. Survive context loss. Query your reasoning.
Documentation
//! Narratives - evolution story management for the decision graph
//!
//! Manages `.deciduous/narratives.md` and provides pivot chain analysis.

use crate::db::{Database, DecisionEdge, DecisionNode};
use colored::Colorize;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::Path;

/// A pivot chain: old approach -> observation -> revisit -> new approach
#[derive(Debug, Serialize)]
pub struct PivotChain {
    pub revisit: NodeRef,
    pub observations: Vec<NodeRef>,
    pub old_approaches: Vec<NodeRef>,
    pub new_approaches: Vec<NodeRef>,
}

/// Lightweight node reference
#[derive(Debug, Serialize)]
pub struct NodeRef {
    pub id: i32,
    pub node_type: String,
    pub title: String,
    pub status: String,
}

fn node_to_ref(node: &DecisionNode) -> NodeRef {
    NodeRef {
        id: node.id,
        node_type: node.node_type.clone(),
        title: node.title.clone(),
        status: node.status.clone(),
    }
}

fn get_branch(node: &DecisionNode) -> Option<String> {
    node.metadata_json
        .as_ref()
        .and_then(|m| serde_json::from_str::<serde_json::Value>(m).ok())
        .and_then(|v| {
            v.get("branch")
                .and_then(|b| b.as_str())
                .map(|s| s.to_string())
        })
}

/// Initialize narratives.md with active goal titles as sections
pub fn init_narratives(db: &Database, output_path: &Path, force: bool) -> Result<(), String> {
    if output_path.exists() && !force {
        return Err(format!(
            "{} already exists. Use --force to overwrite.",
            output_path.display()
        ));
    }

    let nodes = db.get_all_nodes().map_err(|e| e.to_string())?;
    let active_goals: Vec<&DecisionNode> = nodes
        .iter()
        .filter(|n| n.node_type == "goal" && n.status != "superseded" && n.status != "abandoned")
        .collect();

    let mut content = String::from("# Narratives\n\n");
    content.push_str("<!-- Generated by deciduous narratives init -->\n");
    content.push_str("<!-- Fill in evolution stories for each goal -->\n\n");

    if active_goals.is_empty() {
        content.push_str("No active goals found. Add goals with `deciduous add goal \"Title\"`.\n");
    } else {
        for goal in &active_goals {
            content.push_str(&format!("## {}\n", goal.title));
            content.push_str(&format!(
                "> Node #{} | Status: {}\n\n",
                goal.id, goal.status
            ));
            content.push_str("**Current state:** [How it works today]\n\n");
            content.push_str("**Evolution:**\n");
            content.push_str("1. [First approach] - [why]\n");
            content.push_str("2. **PIVOT:** [what changed] - [why it changed]\n");
            content.push_str("3. [Current approach] - [why this is better]\n\n");
            content.push_str("**Evidence:** [PRs, commits, docs]\n");
            content.push_str("**Connects to:** [Other narratives]\n");
            content.push_str("**Status:** active\n\n---\n\n");
        }
    }

    if let Some(parent) = output_path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
    }
    std::fs::write(output_path, content).map_err(|e| e.to_string())?;

    println!(
        "{} {} with {} goal section(s)",
        "Created".green(),
        output_path.display(),
        active_goals.len()
    );

    Ok(())
}

/// Read and display the narratives file
pub fn show_narratives(path: &Path) -> Result<String, String> {
    if !path.exists() {
        return Err(format!(
            "{} not found. Run `deciduous narratives init` first.",
            path.display()
        ));
    }
    std::fs::read_to_string(path).map_err(|e| e.to_string())
}

/// Find all revisit nodes and build their pivot chains
pub fn find_pivots(db: &Database, branch: Option<&str>) -> Result<Vec<PivotChain>, String> {
    let all_nodes = db.get_all_nodes().map_err(|e| e.to_string())?;
    let all_edges = db.get_all_edges().map_err(|e| e.to_string())?;

    // Apply branch filter
    let nodes: Vec<&DecisionNode> = if let Some(br) = branch {
        all_nodes
            .iter()
            .filter(|n| get_branch(n).as_deref() == Some(br))
            .collect()
    } else {
        all_nodes.iter().collect()
    };

    let node_ids: HashSet<i32> = nodes.iter().map(|n| n.id).collect();
    let node_map: HashMap<i32, &DecisionNode> = nodes.iter().map(|n| (n.id, *n)).collect();

    // Build incoming/outgoing edge maps
    let mut incoming: HashMap<i32, Vec<&DecisionEdge>> = HashMap::new();
    let mut outgoing: HashMap<i32, Vec<&DecisionEdge>> = HashMap::new();
    for edge in &all_edges {
        if node_ids.contains(&edge.from_node_id) && node_ids.contains(&edge.to_node_id) {
            incoming.entry(edge.to_node_id).or_default().push(edge);
            outgoing.entry(edge.from_node_id).or_default().push(edge);
        }
    }

    // Find all revisit nodes
    let revisit_nodes: Vec<&&DecisionNode> =
        nodes.iter().filter(|n| n.node_type == "revisit").collect();

    let mut pivots = Vec::new();

    for revisit in revisit_nodes {
        let mut observations = Vec::new();
        let mut old_approaches = Vec::new();
        let mut new_approaches = Vec::new();

        // Incoming edges to revisit -> these are observations or old approaches
        if let Some(in_edges) = incoming.get(&revisit.id) {
            for edge in in_edges {
                if let Some(from_node) = node_map.get(&edge.from_node_id) {
                    if from_node.node_type == "observation" {
                        observations.push(node_to_ref(from_node));
                        // Trace what led to the observation (old approach)
                        if let Some(obs_in) = incoming.get(&from_node.id) {
                            for obs_edge in obs_in {
                                if let Some(old_node) = node_map.get(&obs_edge.from_node_id) {
                                    old_approaches.push(node_to_ref(old_node));
                                }
                            }
                        }
                    } else {
                        // Direct link from old approach to revisit
                        old_approaches.push(node_to_ref(from_node));
                    }
                }
            }
        }

        // Outgoing edges from revisit -> these are new approaches
        if let Some(out_edges) = outgoing.get(&revisit.id) {
            for edge in out_edges {
                if let Some(to_node) = node_map.get(&edge.to_node_id) {
                    new_approaches.push(node_to_ref(to_node));
                }
            }
        }

        pivots.push(PivotChain {
            revisit: node_to_ref(revisit),
            observations,
            old_approaches,
            new_approaches,
        });
    }

    Ok(pivots)
}

/// Print pivot chains in colored terminal format
pub fn print_pivots(pivots: &[PivotChain]) {
    if pivots.is_empty() {
        println!("No pivot points (revisit nodes) found.");
        println!(
            "Create pivots with: {}",
            "deciduous archaeology pivot <from_id> \"observation\" \"new approach\"".dimmed()
        );
        return;
    }

    println!("{} ({} found)", "=== PIVOTS ===".bold(), pivots.len());

    for (i, pivot) in pivots.iter().enumerate() {
        println!();
        println!(
            "Pivot #{}: {} \"{}\"",
            i + 1,
            format!("[revisit #{}]", pivot.revisit.id).yellow(),
            pivot.revisit.title
        );

        for old in &pivot.old_approaches {
            let status_str = if old.status == "superseded" {
                format!(" ({})", "superseded".dimmed())
            } else {
                format!(" ({})", old.status)
            };
            println!(
                "  Old approach: #{} {} \"{}\"{}",
                old.id,
                format!("[{}]", old.node_type).blue(),
                old.title,
                status_str
            );
        }

        for obs in &pivot.observations {
            println!(
                "  Triggered by: #{} {} \"{}\"",
                obs.id,
                format!("[{}]", obs.node_type).blue(),
                obs.title
            );
        }

        for new_approach in &pivot.new_approaches {
            println!(
                "  New approach: #{} {} \"{}\"",
                new_approach.id,
                format!("[{}]", new_approach.node_type).green(),
                new_approach.title
            );
        }

        println!("  {}", "".repeat(50).dimmed());
    }
}