use crate::db::{Database, DecisionEdge, DecisionNode};
use colored::Colorize;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::Path;
#[derive(Debug, Serialize)]
pub struct PivotChain {
pub revisit: NodeRef,
pub observations: Vec<NodeRef>,
pub old_approaches: Vec<NodeRef>,
pub new_approaches: Vec<NodeRef>,
}
#[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())
})
}
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(())
}
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())
}
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())?;
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();
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);
}
}
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();
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));
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 {
old_approaches.push(node_to_ref(from_node));
}
}
}
}
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)
}
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());
}
}