terminal-info 1.5.0

An extensible terminal information CLI and developer toolbox
Documentation
use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

use super::ast::{MathDocument, Statement};

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct VariableInfo {
    pub name: String,
    pub depends_on: Vec<String>,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DependencyGraph {
    pub variables: Vec<VariableInfo>,
    pub cycles: Vec<String>,
}

impl DependencyGraph {
    pub fn mermaid(&self) -> String {
        let mut lines = vec!["graph TD".to_string()];
        for variable in &self.variables {
            if variable.depends_on.is_empty() {
                lines.push(format!("  {}", graph_id(&variable.name)));
            } else {
                for dep in &variable.depends_on {
                    lines.push(format!(
                        "  {} --> {}",
                        graph_id(&variable.name),
                        graph_id(dep)
                    ));
                }
            }
        }
        lines.join("\n")
    }

    pub fn dot(&self) -> String {
        let mut lines = vec!["digraph tinfo_math {".to_string()];
        for variable in &self.variables {
            if variable.depends_on.is_empty() {
                lines.push(format!("  \"{}\";", escape_dot(&variable.name)));
            } else {
                for dep in &variable.depends_on {
                    lines.push(format!(
                        "  \"{}\" -> \"{}\";",
                        escape_dot(&variable.name),
                        escape_dot(dep)
                    ));
                }
            }
        }
        lines.push("}".to_string());
        lines.join("\n")
    }
}

pub fn build_dependency_graph(document: &MathDocument) -> DependencyGraph {
    let mut map: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
    let mut expression_index = 1;
    let mut equation_index = 1;

    for statement in &document.statements {
        match statement {
            Statement::Assignment { name, expr } => {
                let mut deps = BTreeSet::new();
                expr.collect_vars(&mut deps);
                deps.remove(name);
                map.insert(name.clone(), deps);
            }
            Statement::Equation { left, right } => {
                let mut deps = BTreeSet::new();
                left.collect_vars(&mut deps);
                right.collect_vars(&mut deps);
                map.insert(format!("equation_{equation_index}"), deps);
                equation_index += 1;
            }
            Statement::Expression(expr) => {
                let mut deps = BTreeSet::new();
                expr.collect_vars(&mut deps);
                map.insert(format!("expression_{expression_index}"), deps);
                expression_index += 1;
            }
        }
    }

    let variables = map
        .iter()
        .map(|(name, deps)| VariableInfo {
            name: name.clone(),
            depends_on: deps.iter().cloned().collect(),
        })
        .collect::<Vec<_>>();
    let cycles = detect_cycles(&map);
    DependencyGraph { variables, cycles }
}

fn detect_cycles(map: &BTreeMap<String, BTreeSet<String>>) -> Vec<String> {
    let mut cycles = Vec::new();
    for name in map.keys() {
        let mut path = Vec::new();
        if visits(name, name, map, &mut path) {
            cycles.push(path.join(" -> "));
        }
    }
    cycles.sort();
    cycles.dedup();
    cycles
}

fn visits(
    target: &str,
    current: &str,
    map: &BTreeMap<String, BTreeSet<String>>,
    path: &mut Vec<String>,
) -> bool {
    path.push(current.to_string());
    if let Some(deps) = map.get(current) {
        for dep in deps {
            if dep == target {
                path.push(dep.clone());
                return true;
            }
            if map.contains_key(dep)
                && !path.iter().any(|item| item == dep)
                && visits(target, dep, map, path)
            {
                return true;
            }
        }
    }
    path.pop();
    false
}

fn graph_id(value: &str) -> String {
    value
        .chars()
        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' })
        .collect()
}

fn escape_dot(value: &str) -> String {
    value.replace('\\', "\\\\").replace('"', "\\\"")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::math::ast::SourceFormat;
    use crate::math::parser::parse_document;

    #[test]
    fn graph_tracks_dependencies() {
        let doc = parse_document("a=5\nb=a^2", SourceFormat::Math).unwrap();
        let graph = build_dependency_graph(&doc);
        assert!(graph.mermaid().contains("b --> a"));
    }
}